From 9edcf200d23416693dd165700b274231287a2534 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:44:59 -0700 Subject: [PATCH 01/14] iOS: regression tests for bars staying behind a risen keyboard Field repro (IMG_6733.mov): open a workspace with the keyboard recently up, focus input, the keyboard rises but the accessory toolbar and composer band stay seated at the screen bottom behind it. The dock is constrained to UIView.keyboardLayoutGuide, which only reflects transitions UIKit routed to the view's window while it was installed; a test window's guide never moves, which reproduces the missed-transition wedge deterministically. Adds MobileKeyboardFrameTracker (inert in this commit), a DEBUG-only tracker-injection seam, and a per-view notification test seam. The dock tests are red until the dock stops depending on the guide alone. Co-Authored-By: Claude Fable 5 --- .../MobileKeyboardFrameTracker.swift | 85 +++++++++ .../MobileKeyboardFrameTrackerTests.swift | 126 +++++++++++++ .../GhosttySurfaceView.swift | 20 ++ ...GhosttySurfaceKeyboardDockFloorTests.swift | 177 ++++++++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift create mode 100644 Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift create mode 100644 Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift new file mode 100644 index 000000000000..878860fa64c9 --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -0,0 +1,85 @@ +#if canImport(UIKit) +public import UIKit + +/// Process-wide record of the software keyboard's most recent frame transition. +/// +/// `UIView.keyboardLayoutGuide` only reflects keyboard changes UIKit routed to +/// that view's window while the view was installed; a view (re)attached around +/// a workspace switch can miss the transition entirely and stay seated on the +/// guide's bottom-safe-area fallback while the keyboard is up. Keyboard +/// notifications, by contrast, are posted process-wide regardless of any +/// view's attachment, so this tracker is always able to answer "where is the +/// keyboard now?" for late-attaching views. It is a read-only catch-up source: +/// guide-constrained chrome keeps following the guide, and consumers use the +/// tracker only to bound how far below the keyboard that chrome may sit. +@MainActor +public final class MobileKeyboardFrameTracker { + /// The single process-wide tracker. Created on first access; access it + /// before the first keyboard presentation so no transition is missed. + /// The keyboard is process-global UIKit state, and a late-created view + /// must read transitions observed BEFORE it existed — a per-instance + /// observer cannot provide that by construction. Consumers hold an + /// injectable reference (`GhosttySurfaceView.keyboardFrameTracker`), so + /// tests isolate with a private-center instance and never touch this one. + // lint:allow singleton — process-global keyboard state, injectable at use sites. + public static let shared = MobileKeyboardFrameTracker() + + /// The most recent keyboard transition, or `nil` while the keyboard state + /// is unknown (nothing observed yet, keyboard fully hidden, or state + /// discarded on backgrounding because iOS can tear the keyboard down + /// without a paired notification). + public private(set) var latestTransition: MobileKeyboardTransition? + + private nonisolated(unsafe) var tokens: [NSObjectProtocol] = [] + private nonisolated let notificationCenter: NotificationCenter + + /// Creates a tracker subscribed to the keyboard frame notifications. + /// + /// - Parameter notificationCenter: The center to observe; tests inject a + /// private center so posted fixtures cannot leak into other suites. + public init(notificationCenter: NotificationCenter = .default) { + self.notificationCenter = notificationCenter + tokens = [ + notificationCenter.addObserver( + forName: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let transition = MobileKeyboardTransition(notification: notification) + MainActor.assumeIsolated { + guard let transition else { return } + self?.latestTransition = transition + } + }, + notificationCenter.addObserver( + forName: UIResponder.keyboardDidHideNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.latestTransition = nil } + }, + notificationCenter.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.latestTransition = nil } + }, + ] + } + + deinit { + for token in tokens { + notificationCenter.removeObserver(token) + } + } + + /// Returns how much of `view` the tracked keyboard covers from its bottom + /// edge, or zero while the keyboard state is unknown, the view is detached, + /// or the keyboard does not reach the view's bottom (floating/split iPad + /// keyboards, or an end frame parked below the screen after a dismissal). + public func overlap(in view: UIView) -> CGFloat { + latestTransition?.overlap(in: view) ?? 0 + } +} +#endif diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift new file mode 100644 index 000000000000..daa1779d7e3b --- /dev/null +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift @@ -0,0 +1,126 @@ +import Testing +import UIKit + +@testable import CmuxMobileSupport + +@MainActor +@Suite struct MobileKeyboardFrameTrackerTests { + /// Builds the keyboard notification shape UIKit posts, on a private center + /// so fixtures never leak into other suites. + private func post( + _ name: Notification.Name, + endFrame: CGRect? = nil, + to center: NotificationCenter + ) { + var userInfo: [AnyHashable: Any] = [:] + if let endFrame { + userInfo[UIResponder.keyboardFrameEndUserInfoKey] = endFrame + } + center.post(Notification(name: name, object: nil, userInfo: userInfo)) + } + + @Test func willChangeFrameRecordsOverlapForAnAttachedView() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + + @Test func detachedViewReportsZeroOverlapUntilAttached() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let view = UIView(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + // The transition is recorded even though no view can resolve it yet; + // that is the whole point of the tracker. + #expect(tracker.overlap(in: view) == 0) + #expect(tracker.latestTransition != nil) + + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + + @Test func offscreenEndFrameReportsZeroOverlap() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 800, width: 400, height: 300), + to: center + ) + + #expect(tracker.overlap(in: view) == 0) + } + + @Test func didHideClearsTheTrackedTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + #expect(tracker.latestTransition != nil) + + post(UIResponder.keyboardDidHideNotification, to: center) + #expect(tracker.latestTransition == nil) + } + + @Test func backgroundingClearsTheTrackedTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + #expect(tracker.latestTransition != nil) + + post(UIApplication.didEnterBackgroundNotification, to: center) + #expect(tracker.latestTransition == nil) + } + + @Test func notificationWithoutAnEndFrameIsIgnored() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + post(UIResponder.keyboardWillChangeFrameNotification, to: center) + + // A malformed follow-up must not erase the last good transition. + #expect(tracker.latestTransition != nil) + } +} diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 618038032376..2912504d0480 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -514,6 +514,18 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var composerBottomToKeyboardConstraint: NSLayoutConstraint? private var composerHeightConstraint: NSLayoutConstraint? private var toolbarHeightConstraint: NSLayoutConstraint? + /// Process-wide keyboard-frame source for floor catch-up on attach/layout. + /// Only the DEBUG seam below can replace it: tests inject a + /// notification-center-isolated instance without touching the shared + /// tracker other suites read. + private(set) var keyboardFrameTracker: MobileKeyboardFrameTracker = .shared + + #if DEBUG + /// Test seam: swaps the floor's keyboard-frame source for an isolated one. + func setKeyboardFrameTrackerForTesting(_ tracker: MobileKeyboardFrameTracker) { + keyboardFrameTracker = tracker + } + #endif #if DEBUG private var keyboardHeightOverrideForTesting: CGFloat? private var composerBottomForTestingConstraint: NSLayoutConstraint? @@ -1130,6 +1142,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { showZoomOverlay() zoomOverlayLastInteraction = CACurrentMediaTime() + 3600 } + + /// Test seam: routes a keyboard notification through the production + /// `keyboardWillChangeFrame` path for THIS view only, without posting to the + /// process-wide notification center (which would leak keyboard state into + /// concurrently running suites). + func handleKeyboardWillChangeFrameForTesting(_ notification: Notification) { + handleKeyboardWillChangeFrame(notification) + } #endif /// Dock the accessory bar as a persistent bottom toolbar. Auto Layout pins it diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift new file mode 100644 index 000000000000..208b7522551a --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -0,0 +1,177 @@ +#if canImport(UIKit) && DEBUG +import CMUXMobileCore +import CmuxMobileSupport +import Testing +import UIKit + +@testable import CmuxMobileTerminal + +/// Regression coverage for the field failure where the keyboard rises but the +/// bottom bars (accessory toolbar + composer band) stay seated at the +/// keyboard-down position behind it. +/// +/// In a test window UIKit's `keyboardLayoutGuide` never observes a real +/// keyboard, so it stays on its bottom fallback exactly like a guide that +/// missed a live transition around window (re)attachment. Docking correctly +/// here therefore proves the dock does not depend on the guide having seen the +/// keyboard event. Each test injects a notification-center-isolated +/// `MobileKeyboardFrameTracker` (the floor's single data source) and drives the +/// view's notification handler through the test seam for animation-curve +/// application, mirroring the production wiring where the shared tracker +/// observes the same notifications the view does. +@MainActor +@Suite("Keyboard dock floor", .serialized) +struct GhosttySurfaceKeyboardDockFloorTests { + private final class Delegate: NSObject, GhosttySurfaceViewDelegate { + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didProduceInput data: Data + ) {} + + func ghosttySurfaceView( + _ surfaceView: GhosttySurfaceView, + didResize size: TerminalGridSize, + reportID: UInt64 + ) {} + } + + private struct Harness { + let view: GhosttySurfaceView + let window: UIWindow + let center: NotificationCenter + let tracker: MobileKeyboardFrameTracker + /// Retained here because the surface only holds it weakly. + let delegate: Delegate + } + + private static let windowHeight: CGFloat = 874 + private static let keyboardHeight: CGFloat = 336 + + private func makeHarness(attached: Bool = true) throws -> Harness { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let delegate = Delegate() + let view = GhosttySurfaceView( + runtime: try GhosttyRuntime.shared(), + delegate: delegate, + fontSize: 10 + ) + view.autoFocusOnWindowAttach = false + view.isRenderDispatchSuppressed = true + view.setKeyboardFrameTrackerForTesting(tracker) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight)) + if attached { + attach(view, to: window) + } + return Harness(view: view, window: window, center: center, tracker: tracker, delegate: delegate) + } + + private func attach(_ view: GhosttySurfaceView, to window: UIWindow) { + view.frame = window.bounds + window.addSubview(view) + window.isHidden = false + view.setNeedsLayout() + view.layoutIfNeeded() + } + + private func tearDown(_ harness: Harness) { + harness.view.prepareForDismantle() + harness.view.removeFromSuperview() + harness.window.isHidden = true + } + + /// The keyboard notification shape UIKit posts, with `duration` omitted so + /// the transition applies synchronously in tests. + private func keyboardNotification(coveringBottom overlap: CGFloat) -> Notification { + Notification( + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil, + userInfo: [ + UIResponder.keyboardFrameEndUserInfoKey: CGRect( + x: 0, + y: Self.windowHeight - overlap, + width: 402, + height: Self.keyboardHeight + ), + ] + ) + } + + /// Delivers a keyboard transition the way production sees it: the tracker + /// (registered first) records it, then the view's handler re-applies the + /// floor on the notification's animation curve. + private func deliverKeyboardTransition( + coveringBottom overlap: CGFloat, + to harness: Harness + ) { + let notification = keyboardNotification(coveringBottom: overlap) + harness.center.post(notification) + harness.view.handleKeyboardWillChangeFrameForTesting(notification) + harness.view.setNeedsLayout() + harness.view.layoutIfNeeded() + } + + private func probeValue(of view: GhosttySurfaceView, key: String) -> CGFloat? { + let entry = view.composerDockProbeValue + .split(separator: ";") + .first { $0.hasPrefix("\(key)=") } + guard let entry, let value = Double(entry.dropFirst(key.count + 1)) else { + return nil + } + return CGFloat(value) + } + + @Test("keyboard rise docks the bars above it even when the layout guide missed it") + func barsRideTheKeyboardWhenTheGuideStaysOnItsFallback() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + + let dockBottom = Self.windowHeight - Self.keyboardHeight + let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) + let toolbarMaxY = try #require(probeValue(of: harness.view, key: "toolbarMaxY")) + let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(composerMaxY - dockBottom) <= 1) + #expect(abs(toolbarMaxY - dockBottom) <= 1) + #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + } + + @Test("a view attached after the keyboard came up docks from the tracker") + func lateAttachedViewDocksFromTheTrackedKeyboardFrame() throws { + let harness = try makeHarness(attached: false) + defer { tearDown(harness) } + + // The keyboard transition happens while the view is detached — the + // workspace-switch case. Only the tracker observes it; the view's + // handler never runs for it. + harness.center.post(keyboardNotification(coveringBottom: Self.keyboardHeight)) + attach(harness.view, to: harness.window) + + let dockBottom = Self.windowHeight - Self.keyboardHeight + let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) + let toolbarMaxY = try #require(probeValue(of: harness.view, key: "toolbarMaxY")) + let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(composerMaxY - dockBottom) <= 1) + #expect(abs(toolbarMaxY - dockBottom) <= 1) + #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + } + + @Test("a dismissal transition releases the floor and reseats the bars at the bottom") + func dismissalReturnsTheBarsToTheBottomFallback() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + deliverKeyboardTransition(coveringBottom: 0, to: harness) + + // Released, the dock reseats on the guide's bottom fallback, which + // clears the simulator device's real bottom safe-area inset. + let bottomSafeArea = try #require(probeValue(of: harness.view, key: "bottomSafeArea")) + let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) + let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(composerMaxY - (Self.windowHeight - bottomSafeArea)) <= 1) + #expect(abs(modelKeyboardHeight) <= 1) + } +} +#endif From b0e29191a35228c5beda3f741f8e78db89a0863e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:45:01 -0700 Subject: [PATCH 02/14] iOS: floor the bottom dock at the notification-tracked keyboard frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dock (accessory toolbar + composer band) follows UIView.keyboardLayoutGuide. The guide only observes keyboard transitions UIKit routed to the view's window while the view was installed, so a surface (re)attached around a workspace switch can miss the rise entirely and stay seated on the guide's bottom-safe-area fallback while the keyboard covers it — the bars vanish behind the keyboard and the grid keeps keyboard-down geometry (IMG_6733.mov). Keyboard notifications are posted process-wide regardless of attachment, so MobileKeyboardFrameTracker records the latest keyboard end frame as the floor's single data source, and the dock gains a REQUIRED inequality floor (composer.bottom <= view.bottom - tracked overlap) beneath the guide equality, which drops one priority notch. The guide remains the movement engine; the floor only stops the dock from sitting below the real keyboard. The per-view notification handler re-reads the tracker on the notification's own animation curve, and layout passes catch late-attached views up from the same tracker, so the two paths can never disagree. The viewport model takes max(guide, floor) so the terminal grid reserves exactly the space the lifted bars occupy. Co-Authored-By: Claude Fable 5 --- .../GhosttySurfaceView.swift | 100 ++++++++++++++++-- 1 file changed, 92 insertions(+), 8 deletions(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 2912504d0480..2cea60518932 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -514,6 +514,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var composerBottomToKeyboardConstraint: NSLayoutConstraint? private var composerHeightConstraint: NSLayoutConstraint? private var toolbarHeightConstraint: NSLayoutConstraint? + /// Required upper bound on the composer's bottom edge derived from the + /// notification-tracked keyboard frame: `composer.bottom <= self.bottom - + /// keyboardOverlapFloor`. The keyboard guide equality above it is demoted one + /// priority notch, so when the guide misses a transition (a view attached + /// around a workspace switch can be left seated on the guide's safe-area + /// fallback while the keyboard is up) this floor still lifts the dock above + /// the real keyboard. While the guide tracks correctly both constraints agree + /// and the floor is inert. + private var composerBottomKeyboardFloorConstraint: NSLayoutConstraint? /// Process-wide keyboard-frame source for floor catch-up on attach/layout. /// Only the DEBUG seam below can replace it: tests inject a /// notification-center-isolated instance without touching the shared @@ -526,6 +535,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { keyboardFrameTracker = tracker } #endif + /// The keyboard overlap the floor constraint currently enforces, mirrored + /// into the viewport model so the terminal grid reserves the same space the + /// bars occupy. Zero while the keyboard is down or unknown. + private var keyboardOverlapFloor: CGFloat = 0 #if DEBUG private var keyboardHeightOverrideForTesting: CGFloat? private var composerBottomForTestingConstraint: NSLayoutConstraint? @@ -990,12 +1003,27 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // and its text stays; tapping it refocuses and re-raises the keyboard. The // composer is dismissed only by its chevron or the toolbar composer button. // - // This notification owns responder-facing VISIBILITY only. UIKit's - // `keyboardLayoutGuide` owns all dock and viewport geometry, including safe-area - // fallback and interrupted keyboard motion. Keeping those responsibilities - // separate prevents a stale notification frame from becoming a second layout - // authority while preserving hardware/floating keyboard visibility semantics. + // This notification owns responder-facing VISIBILITY plus the keyboard + // FLOOR. UIKit's `keyboardLayoutGuide` still owns dock and viewport motion, + // including safe-area fallback and interrupted keyboard motion; the floor is + // an inequality bound, not a second equality authority, so a stale frame can + // never pin the dock below wherever the guide places it. It exists because + // the guide can miss a transition around window (re)attachment and stay on + // its safe-area fallback with the keyboard up; the floor then lifts the dock + // on the notification's own curve. updateDockedToolbarVisibility() + // The tracker (registered before any surface's handler) is the floor's + // ONLY data source; this handler merely re-reads it on the keyboard's + // own animation curve so a floor change moves like the keyboard. A + // second per-view derivation here could disagree with the tracker and + // be undone by the next layout catch-up. The renderer follows through + // the display-link transition pass, exactly as for guide-driven motion. + transition.animate { [weak self] in + guard let self else { return } + if self.synchronizeKeyboardFloorFromTracker() { + self.layoutIfNeeded() + } + } setNeedsLayout() } @@ -1015,10 +1043,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Installs the system keyboard guide as the only production keyboard geometry source. + /// Installs the system keyboard guide as the primary keyboard geometry source. private func configureKeyboardLayoutGuide() { keyboardLayoutGuide.followsUndockedKeyboard = false keyboardLayoutGuide.usesBottomSafeArea = true + // Create the process-wide tracker no later than the first surface, so a + // keyboard transition that happens while THIS view is detached (workspace + // switch) is still observed and can seat the keyboard floor on attach. + _ = MobileKeyboardFrameTracker.shared } /// Pins the whole dock stack to Apple's keyboard guide. @@ -1030,9 +1062,17 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let composerBottom = composerContainer.bottomAnchor.constraint( equalTo: keyboardLayoutGuide.topAnchor ) + // One notch below required so the notification-derived keyboard floor + // below can outrank a guide that missed the current keyboard transition; + // see `composerBottomKeyboardFloorConstraint`. + composerBottom.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 1) + let composerBottomFloor = composerContainer.bottomAnchor.constraint( + lessThanOrEqualTo: bottomAnchor + ) let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0) let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0) composerBottomToKeyboardConstraint = composerBottom + composerBottomKeyboardFloorConstraint = composerBottomFloor composerHeightConstraint = composerHeight self.toolbarHeightConstraint = toolbarHeight @@ -1040,6 +1080,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { composerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), composerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), composerBottom, + composerBottomFloor, composerHeight, dockedToolbar.leadingAnchor.constraint(equalTo: leadingAnchor), dockedToolbar.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -1049,10 +1090,49 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { layoutBottomDock() } - /// Updates the renderer's overlap model from the guide's target top edge. + /// Re-derives the keyboard floor and applies it to the floor constraint and + /// the viewport model. + /// + /// - Parameter overlap: The keyboard overlap to enforce, in this view's + /// coordinates; pass the notification transition's overlap on the + /// notification path, or the tracker-derived overlap on layout catch-up. + /// - Returns: Whether the floor changed. + @discardableResult + private func applyKeyboardOverlapFloor(_ overlap: CGFloat) -> Bool { + #if DEBUG + guard keyboardHeightOverrideForTesting == nil else { return false } + #endif + let clamped = max(0, overlap) + guard abs(clamped - keyboardOverlapFloor) > 0.25 else { return false } + keyboardOverlapFloor = clamped + composerBottomKeyboardFloorConstraint?.constant = -clamped + setNeedsGeometrySync() + return true + } + + /// Catches the keyboard floor up from the process-wide tracker. + /// + /// A view attached to a window AFTER the keyboard came up never receives the + /// keyboard notification for that transition, and UIKit's layout guide can + /// stay on its safe-area fallback for the same reason. The tracker observed + /// the transition process-wide, so every layout pass can re-derive the floor + /// for the view's current window position. + /// + /// - Returns: Whether the floor changed. + @discardableResult + private func synchronizeKeyboardFloorFromTracker() -> Bool { + guard window != nil else { return false } + return applyKeyboardOverlapFloor(keyboardFrameTracker.overlap(in: self)) + } + + /// Updates the renderer's overlap model from the guide's target top edge, + /// bounded below by the notification-derived keyboard floor so the grid + /// reserves the same space the floor-lifted bars occupy when the guide + /// missed the current transition. @discardableResult private func synchronizeKeyboardGeometryFromLayoutGuide() -> Bool { - let nextHeight = keyboardOverlapFromLayoutGuide + synchronizeKeyboardFloorFromTracker() + let nextHeight = max(keyboardOverlapFromLayoutGuide, keyboardOverlapFloor) guard abs(nextHeight - keyboardHeight) > 0.25 else { return false } keyboardHeight = nextHeight bottomDockTransitionObserved = bottomDockTransitionInFlight @@ -1101,6 +1181,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { keyboardHeight = clamped bottomDockTransitionObserved = false + // The synthetic bottom equality replaces BOTH production keyboard + // constraints; a live floor would make an override below it unsatisfiable. + keyboardOverlapFloor = 0 + composerBottomKeyboardFloorConstraint?.constant = 0 composerBottomToKeyboardConstraint?.isActive = false if composerBottomForTestingConstraint == nil { composerBottomForTestingConstraint = composerContainer.bottomAnchor.constraint( From b74ab85c3292f19d8243175e65d669b5827b8ef4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:12:49 -0700 Subject: [PATCH 03/14] iOS: unbreak MobileInjectedAttachStartupTests so the xctestplan compiles The suite was written against a connectInjectedAttach(_:attachURL:_:) coordinator API that was reworked before #9252 merged, so the file has never compiled. cmux.xctestplan builds every member test target even under -only-testing, so this one broken target has been failing the whole 'iOS simulator tests' iphone lane for every dispatch since. Rewrite the suite against the coordinator API that shipped, preserving the admission contract: a connected injected attach consumes startup and blocks the saved-Mac reconnect, a failed one releases startup to it, and only one startup source can claim admission. The original's URL-connect side-effect assertion lives in CMUXMobileRootView's path and was never compilable here. Co-Authored-By: Claude Fable 5 --- .../MobileInjectedAttachStartupTests.swift | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift index e7dfc1f447ab..60ca9f778d4c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift @@ -1,41 +1,55 @@ -import CmuxMobileShellModel import Testing @testable import CmuxMobileShellUI +/// Startup admission contract for an explicitly injected attach URL. +/// +/// The original version of this suite was written against a +/// `connectInjectedAttach(_:attachURL:_:)` coordinator API that was reworked +/// before it merged, so the file has not compiled since it landed — and a +/// compile-broken test target blocks every `cmux.xctestplan` run (the plan +/// builds all of its targets even under `-only-testing`). These tests assert +/// the same admission contract against the coordinator API that shipped; the +/// URL-connect side effects live in `CMUXMobileRootView`, which claims and +/// finishes attempts through exactly these entry points. @Suite struct MobileInjectedAttachStartupTests { @Test @MainActor - func beginsRouteAdmissionWithoutAnExternalTransportReadinessBarrier() async throws { + func connectedInjectedAttachConsumesStartupWithoutFallback() throws { let coordinator = MobileStartupConnectionCoordinator() let attempt = try #require(coordinator.claimInjectedAttach()) - let recorder = MobileInjectedAttachURLRecorder() - let attachURL = "cmux-ios://attach?v=2&payload=iroh-route" - - let completion = await coordinator.connectInjectedAttach( - attempt, - attachURL: attachURL - ) { rawURL in - await recorder.record(rawURL) - return MobilePairingURLConnectionResult.connected - } - - let completedAttempt = try #require(completion) - #expect(await recorder.values() == [attachURL]) - #expect(completedAttempt.result == .connected) - #expect(!completedAttempt.shouldReconnectStoredMac) + + let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .connected) + + #expect(!shouldFallBack) + #expect(!coordinator.shouldFallBackFromInjectedAttach) + // A consumed explicit route keeps owning startup: the saved-Mac + // reconnect must not also dial. #expect(coordinator.claimStoredReconnect() == nil) } -} -private actor MobileInjectedAttachURLRecorder { - private var urls: [String] = [] + @Test + @MainActor + func failedInjectedAttachReleasesStartupToStoredReconnect() throws { + let coordinator = MobileStartupConnectionCoordinator() + let attempt = try #require(coordinator.claimInjectedAttach()) - func record(_ url: String) { - urls.append(url) + let shouldFallBack = coordinator.finishInjectedAttach(attempt, outcome: .failed) + + #expect(shouldFallBack) + #expect(coordinator.shouldFallBackFromInjectedAttach) + // A failed explicit route releases startup so the authenticated shell + // is not stranded disconnected. + #expect(coordinator.claimStoredReconnect() != nil) } - func values() -> [String] { - urls + @Test + @MainActor + func onlyOneStartupSourceCanClaimAdmission() throws { + let coordinator = MobileStartupConnectionCoordinator() + + #expect(coordinator.claimInjectedAttach() != nil) + #expect(coordinator.claimInjectedAttach() == nil) + #expect(coordinator.claimStoredReconnect() == nil) } } From eaf3cce539dee6de92396aa99800ce5bf028bfe4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:52:13 -0700 Subject: [PATCH 04/14] iOS: make the dock floor's keyboard tracker an init-injected dependency Review feedback (CodeRabbit): drop the DEBUG-only tracker swap and the ForTesting notification wrapper. The surface now takes the tracker as an immutable init dependency defaulting to the shared process-wide instance, tests inject a notification-center-isolated tracker through the initializer and call the internal handler via @testable import. A keyboardWillChangeFrame without a readable end frame now clears the tracked transition (fail closed) instead of preserving a stale floor. Co-Authored-By: Claude Fable 5 --- .../MobileKeyboardFrameTracker.swift | 12 ++++--- .../MobileKeyboardFrameTrackerTests.swift | 7 ++-- .../GhosttySurfaceView.swift | 34 ++++++++----------- ...GhosttySurfaceKeyboardDockFloorTests.swift | 14 ++++---- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift index 878860fa64c9..cb7d69a8ca21 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -25,9 +25,10 @@ public final class MobileKeyboardFrameTracker { public static let shared = MobileKeyboardFrameTracker() /// The most recent keyboard transition, or `nil` while the keyboard state - /// is unknown (nothing observed yet, keyboard fully hidden, or state - /// discarded on backgrounding because iOS can tear the keyboard down - /// without a paired notification). + /// is unknown (nothing observed yet, keyboard fully hidden, a transition + /// posted without a readable end frame, or state discarded on + /// backgrounding because iOS can tear the keyboard down without a paired + /// notification). public private(set) var latestTransition: MobileKeyboardTransition? private nonisolated(unsafe) var tokens: [NSObjectProtocol] = [] @@ -47,7 +48,10 @@ public final class MobileKeyboardFrameTracker { ) { [weak self] notification in let transition = MobileKeyboardTransition(notification: notification) MainActor.assumeIsolated { - guard let transition else { return } + // A transition without a readable end frame leaves the + // keyboard state unknown; fail closed (clear) so a stale + // floor can never keep the dock and viewport raised after + // the keyboard actually changed. self?.latestTransition = transition } }, diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift index daa1779d7e3b..3b6f6a26045f 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift @@ -109,7 +109,7 @@ import UIKit #expect(tracker.latestTransition == nil) } - @Test func notificationWithoutAnEndFrameIsIgnored() { + @Test func notificationWithoutAnEndFrameClearsTheTrackedTransition() { let center = NotificationCenter() let tracker = MobileKeyboardFrameTracker(notificationCenter: center) @@ -120,7 +120,8 @@ import UIKit ) post(UIResponder.keyboardWillChangeFrameNotification, to: center) - // A malformed follow-up must not erase the last good transition. - #expect(tracker.latestTransition != nil) + // An unreadable follow-up means the keyboard state is unknown; fail + // closed so a stale floor cannot keep the dock raised. + #expect(tracker.latestTransition == nil) } } diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 2cea60518932..cd5d1da50fe6 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -524,17 +524,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// and the floor is inert. private var composerBottomKeyboardFloorConstraint: NSLayoutConstraint? /// Process-wide keyboard-frame source for floor catch-up on attach/layout. - /// Only the DEBUG seam below can replace it: tests inject a - /// notification-center-isolated instance without touching the shared - /// tracker other suites read. - private(set) var keyboardFrameTracker: MobileKeyboardFrameTracker = .shared - - #if DEBUG - /// Test seam: swaps the floor's keyboard-frame source for an isolated one. - func setKeyboardFrameTrackerForTesting(_ tracker: MobileKeyboardFrameTracker) { - keyboardFrameTracker = tracker - } - #endif + /// Injected at construction (production callers take the shared tracker + /// default) so tests pass a notification-center-isolated instance without + /// touching the shared tracker other suites read. + private let keyboardFrameTracker: MobileKeyboardFrameTracker /// The keyboard overlap the floor constraint currently enforces, mirrored /// into the viewport model so the terminal grid reserves the same space the /// bars occupy. Zero while the keyboard is down or unknown. @@ -757,11 +750,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// - terminalTheme: Renderer-effective colors used by surrounding UIKit chrome. /// - terminalConfigTheme: Raw Ghostty configuration defaults. Defaults to /// `terminalTheme` for callers that do not mirror a remote surface. + /// - keyboardFrameTracker: Keyboard-frame source for the bottom-dock + /// floor. Production callers keep the shared process-wide tracker; + /// tests inject a notification-center-isolated instance. public init(runtime: GhosttyRuntime, delegate: GhosttySurfaceViewDelegate, fontSize: Float32 = 10, terminalTheme: TerminalTheme = .monokai, - terminalConfigTheme: TerminalTheme? = nil) { + terminalConfigTheme: TerminalTheme? = nil, + keyboardFrameTracker: MobileKeyboardFrameTracker = .shared) { self.runtime = runtime self.delegate = delegate + self.keyboardFrameTracker = keyboardFrameTracker self.fontSize = fontSize self.liveFontSize = fontSize self.userBaseFontSize = fontSize @@ -974,7 +972,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// whichever registered surface happens to sort first. public var hostSurfaceID: String? - @objc private func handleKeyboardWillChangeFrame(_ notification: Notification) { + // Internal so tests can route a fixture notification through the + // production path for THIS view only (posting to the process-wide center + // would leak keyboard state into concurrently running suites). + @objc func handleKeyboardWillChangeFrame(_ notification: Notification) { guard let transition = MobileKeyboardTransition(notification: notification) else { return } let willBeVisible = transition.isVisible(in: self) let wasVisible = keyboardVisible @@ -1227,13 +1228,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { zoomOverlayLastInteraction = CACurrentMediaTime() + 3600 } - /// Test seam: routes a keyboard notification through the production - /// `keyboardWillChangeFrame` path for THIS view only, without posting to the - /// process-wide notification center (which would leak keyboard state into - /// concurrently running suites). - func handleKeyboardWillChangeFrameForTesting(_ notification: Notification) { - handleKeyboardWillChangeFrame(notification) - } #endif /// Dock the accessory bar as a persistent bottom toolbar. Auto Layout pins it diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift index 208b7522551a..f8234e0253f5 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -15,10 +15,10 @@ import UIKit /// missed a live transition around window (re)attachment. Docking correctly /// here therefore proves the dock does not depend on the guide having seen the /// keyboard event. Each test injects a notification-center-isolated -/// `MobileKeyboardFrameTracker` (the floor's single data source) and drives the -/// view's notification handler through the test seam for animation-curve -/// application, mirroring the production wiring where the shared tracker -/// observes the same notifications the view does. +/// `MobileKeyboardFrameTracker` (the floor's single data source) through the +/// surface initializer and calls the view's notification handler directly for +/// animation-curve application, mirroring the production wiring where the +/// shared tracker observes the same notifications the view does. @MainActor @Suite("Keyboard dock floor", .serialized) struct GhosttySurfaceKeyboardDockFloorTests { @@ -54,11 +54,11 @@ struct GhosttySurfaceKeyboardDockFloorTests { let view = GhosttySurfaceView( runtime: try GhosttyRuntime.shared(), delegate: delegate, - fontSize: 10 + fontSize: 10, + keyboardFrameTracker: tracker ) view.autoFocusOnWindowAttach = false view.isRenderDispatchSuppressed = true - view.setKeyboardFrameTrackerForTesting(tracker) let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight)) if attached { attach(view, to: window) @@ -106,7 +106,7 @@ struct GhosttySurfaceKeyboardDockFloorTests { ) { let notification = keyboardNotification(coveringBottom: overlap) harness.center.post(notification) - harness.view.handleKeyboardWillChangeFrameForTesting(notification) + harness.view.handleKeyboardWillChangeFrame(notification) harness.view.setNeedsLayout() harness.view.layoutIfNeeded() } From b0626fa6d01bc217fab13574014a7e73b34b3963 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:04:23 -0700 Subject: [PATCH 05/14] iOS: cover the approval-gated injected attach admission path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback (CodeRabbit): finishInjectedAttach treats .awaitingUserApproval like .connected — startup stays consumed and the saved-Mac reconnect must not dial under the approval prompt. Only .connected was covered, so a regression in the approval arm could pass. Co-Authored-By: Claude Fable 5 --- .../MobileInjectedAttachStartupTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift index 60ca9f778d4c..a68e4d59f35a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileInjectedAttachStartupTests.swift @@ -28,6 +28,24 @@ struct MobileInjectedAttachStartupTests { #expect(coordinator.claimStoredReconnect() == nil) } + @Test + @MainActor + func approvalGatedInjectedAttachConsumesStartupWithoutFallback() throws { + let coordinator = MobileStartupConnectionCoordinator() + let attempt = try #require(coordinator.claimInjectedAttach()) + + let shouldFallBack = coordinator.finishInjectedAttach( + attempt, outcome: .awaitingUserApproval + ) + + #expect(!shouldFallBack) + #expect(!coordinator.shouldFallBackFromInjectedAttach) + // An attach parked on the Mac-side approval prompt still owns startup: + // the saved-Mac reconnect dialing underneath it would race the very + // connection the user is approving. + #expect(coordinator.claimStoredReconnect() == nil) + } + @Test @MainActor func failedInjectedAttachReleasesStartupToStoredReconnect() throws { From 5ecfb177fc1efb32b75ffd0c89890d48e70ead96 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:20:12 -0700 Subject: [PATCH 06/14] iOS: regression tests for the stale keyboard-toggle glyph after re-entry Leaving the workspace detail with the keyboard up and re-entering shows the hide-keyboard glyph (and keyboardUp=1) although the keyboard is down: the toolbar button is constructed in the keyboard-up state and nothing reconciles the visibility bit when a surface (re)mounts without a keyboard event. Red until the visibility state catches up from the tracked keyboard frame. Co-Authored-By: Claude Fable 5 --- ...GhosttySurfaceKeyboardDockFloorTests.swift | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift index f8234e0253f5..c242957829f5 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -121,6 +121,21 @@ struct GhosttySurfaceKeyboardDockFloorTests { return CGFloat(value) } + /// The accessory toolbar's keyboard-toggle button, found by its stable + /// accessibility identifier in the surface's subtree. + private func keyboardToggleButton(in view: UIView) -> UIButton? { + if let button = view as? UIButton, + button.accessibilityIdentifier == "terminal.inputAccessory.hideKeyboard" { + return button + } + for subview in view.subviews { + if let found = keyboardToggleButton(in: subview) { + return found + } + } + return nil + } + @Test("keyboard rise docks the bars above it even when the layout guide missed it") func barsRideTheKeyboardWhenTheGuideStaysOnItsFallback() throws { let harness = try makeHarness() @@ -152,9 +167,50 @@ struct GhosttySurfaceKeyboardDockFloorTests { let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) let toolbarMaxY = try #require(probeValue(of: harness.view, key: "toolbarMaxY")) let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) #expect(abs(composerMaxY - dockBottom) <= 1) #expect(abs(toolbarMaxY - dockBottom) <= 1) #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + // The visibility bit catches up too: the toggle must read hide-keyboard. + #expect(keyboardUp == 1) + } + + @Test("a fresh toolbar is born in the keyboard-down state") + func freshToolbarShowsTheShowKeyboardToggle() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + let toggle = try #require(keyboardToggleButton(in: harness.view)) + #expect(toggle.accessibilityLabel == "Show Keyboard") + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) + #expect(keyboardUp == 0) + } + + @Test("re-entering after the keyboard dismissed while detached resets the visibility state") + func reattachAfterDetachedDismissalShowsKeyboardDownState() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + // Keyboard up while the surface is presented. + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + #expect(try #require(probeValue(of: harness.view, key: "keyboardUp")) == 1) + + // Leave the workspace detail: the surface detaches, THEN the keyboard + // dismisses. Only the tracker observes the dismissal. + harness.view.removeFromSuperview() + harness.center.post(keyboardNotification(coveringBottom: 0)) + + // Re-enter: the visibility bit, the toggle glyph state, and the dock + // must all reflect the keyboard-down truth. + attach(harness.view, to: harness.window) + + let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) + let bottomSafeArea = try #require(probeValue(of: harness.view, key: "bottomSafeArea")) + let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) + let toggle = try #require(keyboardToggleButton(in: harness.view)) + #expect(keyboardUp == 0) + #expect(abs(composerMaxY - (Self.windowHeight - bottomSafeArea)) <= 1) + #expect(toggle.accessibilityLabel == "Show Keyboard") } @Test("a dismissal transition releases the floor and reseats the bars at the bottom") From 984c5855fe6f7ec477d6ae2d28aacefeaee280b0 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:20:12 -0700 Subject: [PATCH 07/14] iOS: reconcile keyboard visibility (and the toggle glyph) on remount The toolbar's keyboard-toggle glyph and the surface's keyboardVisible bit were only updated from keyboard notifications, so a surface (re)mounted after the keyboard changed kept the stale state: re-entering a workspace left with the keyboard up showed the hide-keyboard glyph over a dismissed keyboard, and the toggle resigned a keyboard that was not there. The dismiss button is now born in the keyboard-down state, and the layout catch-up that already re-derives the dock floor from MobileKeyboardFrameTracker also reconciles keyboardVisible (change-guarded so the glyph cross-dissolve only runs on real flips). The tracker gains isVisible(in:), mirroring the notification path's floating-keyboard semantics. Co-Authored-By: Claude Fable 5 --- .../MobileKeyboardFrameTracker.swift | 7 +++++++ .../CmuxMobileTerminal/GhosttySurfaceView.swift | 17 +++++++++++++++++ .../TerminalInputTextView.swift | 9 +++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift index cb7d69a8ca21..fecd2ad479e8 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -85,5 +85,12 @@ public final class MobileKeyboardFrameTracker { public func overlap(in view: UIView) -> CGFloat { latestTransition?.overlap(in: view) ?? 0 } + + /// Returns whether the tracked keyboard is visible to `view` (including + /// floating/split iPad keyboards that reserve no bottom space), or false + /// while the keyboard state is unknown or the view is detached. + public func isVisible(in view: UIView) -> Bool { + latestTransition?.isVisible(in: view) ?? false + } } #endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index cd5d1da50fe6..bc11672838df 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1123,9 +1123,26 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { @discardableResult private func synchronizeKeyboardFloorFromTracker() -> Bool { guard window != nil else { return false } + synchronizeKeyboardVisibilityFromTracker() return applyKeyboardOverlapFloor(keyboardFrameTracker.overlap(in: self)) } + /// Reconciles the responder-facing visibility bit — and with it the + /// toolbar's keyboard-toggle glyph — with the tracked keyboard state. + /// + /// A surface (re)mounted after the keyboard changed never receives the + /// notification that flipped it, so leaving a workspace with the keyboard + /// up and re-entering it otherwise shows the stale hide-keyboard glyph + /// (and the toggle would resign a keyboard that is not there) until the + /// next real transition. Change-guarded so the glyph cross-dissolve only + /// runs on actual flips, not every layout pass. + private func synchronizeKeyboardVisibilityFromTracker() { + let visible = keyboardFrameTracker.isVisible(in: self) + guard visible != keyboardVisible else { return } + keyboardVisible = visible + inputProxy.setKeyboardShown(visible) + } + /// Updates the renderer's overlap model from the guide's target top edge, /// bounded below by the notification-derived keyboard floor so the grid /// reserves the same space the floor-lifted bars occupy when the guide diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 50678f5095b7..35c9aa086ea8 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -351,11 +351,16 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { // Pinned keyboard dismiss button on the left let dismissButton = UIButton(type: .system) - dismissButton.setImage(UIImage(systemName: "keyboard.chevron.compact.down", withConfiguration: Self.accessoryButtonSymbolConfig), for: .normal) + // Born in the keyboard-DOWN state: a freshly (re)mounted surface has no + // keyboard until something focuses, and `setKeyboardShown(_:)` flips the + // glyph on real transitions. Constructing with the chevron-down glyph + // showed a stale "hide keyboard" toggle whenever a workspace was + // re-entered with the keyboard dismissed. + dismissButton.setImage(UIImage(systemName: "keyboard", withConfiguration: Self.accessoryButtonSymbolConfig), for: .normal) dismissButton.tintColor = themeChromeColor.withAlphaComponent(0.78) dismissButton.addTarget(self, action: #selector(handleHideKeyboard), for: .touchUpInside) dismissButton.accessibilityIdentifier = "terminal.inputAccessory.hideKeyboard" - dismissButton.accessibilityLabel = String(localized: "terminal.input_accessory.hideKeyboard", defaultValue: "Hide Keyboard") + dismissButton.accessibilityLabel = String(localized: "terminal.input_accessory.showKeyboard", defaultValue: "Show Keyboard") dismissButton.translatesAutoresizingMaskIntoConstraints = false self.dismissButton = dismissButton From db98aa6333970c36f8ed70e9f42a7462dfcb7f8b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:06:46 -0700 Subject: [PATCH 08/14] iOS: track the keyboard directly so the dock rides its animation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dogfood recording (2026-08-05 20:48): the keyboard rose over the bars, which snapped up only after it settled — on every rise, including rapid toggles. With usesBottomSafeArea=true the keyboard guide's position is coupled to bottom-safe-area propagation, which lands at the END of a keyboard transition, so the guide-constrained dock moved late; the same coupling explains the settled guide reading the bottom inset below the notification frame on the simulator. Switch the guide to pure keyboard tracking (usesBottomSafeArea=false) and express the keyboard-down seat explicitly: a required cap keeps the dock above the bottom safe area, decisive only while the keyboard is down (the guide equality and notification floor are stricter when it is up). The existing dismissal test covers the cap (it fails without it in the new guide mode). DEBUG forensics for this class of bug: kb.willChange logs the notification frame, tracker/guide/floor trio, and duration; kb.floor marks late floor application, so a recording can be lined up against which source moved the dock and when. Co-Authored-By: Claude Fable 5 --- .../GhosttySurfaceView.swift | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index bc11672838df..d4a9c0ee7ef1 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -1013,6 +1013,19 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // its safe-area fallback with the keyboard up; the floor then lifts the dock // on the notification's own curve. updateDockedToolbarVisibility() + #if DEBUG + // Transition forensics for the bars-lag-the-keyboard class: one line per + // willChangeFrame with the notification frame, the tracker/guide/floor + // trio, and the animation duration, so a dogfood recording can be lined + // up against which source moved (or failed to move) the dock and when. + MobileDebugLog.anchormux( + "kb.willChange endMinY=\(Int(transition.endFrame.minY)) endH=\(Int(transition.endFrame.height))" + + " dur=\(String(format: "%.2f", transition.duration))" + + " trackerOverlap=\(Int(keyboardFrameTracker.overlap(in: self)))" + + " guideTop=\(Int(keyboardLayoutGuide.layoutFrame.minY))" + + " floor=\(Int(keyboardOverlapFloor)) visible=\(willBeVisible ? 1 : 0)" + ) + #endif // The tracker (registered before any surface's handler) is the floor's // ONLY data source; this handler merely re-reads it on the keyboard's // own animation curve so a floor change moves like the keyboard. A @@ -1047,7 +1060,16 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Installs the system keyboard guide as the primary keyboard geometry source. private func configureKeyboardLayoutGuide() { keyboardLayoutGuide.followsUndockedKeyboard = false - keyboardLayoutGuide.usesBottomSafeArea = true + // Pure keyboard tracking. With `usesBottomSafeArea = true` the guide's + // position is coupled to bottom-safe-area propagation, which lands at + // the END of a keyboard transition: on device the guide-constrained + // bars sat still while the keyboard rose over them, then snapped up + // after it settled (dogfood recording, 2026-08-05), and on the + // simulator the settled guide read the bottom inset (~34pt) below the + // notification frame. The keyboard-down seat above the home indicator + // that `usesBottomSafeArea` provided is expressed explicitly in + // `installBottomDockConstraints()` via a required safe-area cap. + keyboardLayoutGuide.usesBottomSafeArea = false // Create the process-wide tracker no later than the first surface, so a // keyboard transition that happens while THIS view is detached (workspace // switch) is still observed and can seat the keyboard floor on attach. @@ -1070,6 +1092,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let composerBottomFloor = composerContainer.bottomAnchor.constraint( lessThanOrEqualTo: bottomAnchor ) + // With the guide in pure keyboard mode its keyboard-DOWN position is the + // view's bottom edge, so the home-indicator seat is stated explicitly: + // the dock may never sink into the bottom safe area. While the keyboard + // is up, the guide equality and the keyboard floor are both stricter, so + // this cap only decides the keyboard-down seat. + let composerBottomSafeAreaCap = composerContainer.bottomAnchor.constraint( + lessThanOrEqualTo: safeAreaLayoutGuide.bottomAnchor + ) let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0) let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0) composerBottomToKeyboardConstraint = composerBottom @@ -1082,6 +1112,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { composerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), composerBottom, composerBottomFloor, + composerBottomSafeAreaCap, composerHeight, dockedToolbar.leadingAnchor.constraint(equalTo: leadingAnchor), dockedToolbar.trailingAnchor.constraint(equalTo: trailingAnchor), @@ -1107,6 +1138,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { guard abs(clamped - keyboardOverlapFloor) > 0.25 else { return false } keyboardOverlapFloor = clamped composerBottomKeyboardFloorConstraint?.constant = -clamped + #if DEBUG + // Paired with kb.willChange: a kb.floor long after its kb.willChange is + // the late-snap signature (floor applied by layout catch-up, unanimated). + MobileDebugLog.anchormux("kb.floor \(Int(clamped))") + #endif setNeedsGeometrySync() return true } From aa7749d06caca400185143e76594be1f639f3c7e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:36:26 -0700 Subject: [PATCH 09/14] iOS: pin the terminal surface's frame across keyboard transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device forensics (kb.willChange/kb.floor, 2026-08-06): every keyboard rise applied the dock floor at willChangeFrame with an overlap 34pt short of the settled truth, corrected ~1s later by a layout catch-up — the bars visibly popped by the home-indicator height after each rise. Cause: the surface ignored only the KEYBOARD safe area, so its bottom edge respected the home indicator while the keyboard was down but extended to the window bottom while it was up (the keyboard region subsumes the indicator inset). The frame breathed by 34pt on every transition, and converting the (final) notification end frame through the mid-animation frame under-measured. Extend the surface under the home indicator in ALL states so its frame is keyboard-invariant; the dock's required safe-area cap (previous commit) keeps the bars clear of the indicator while the keyboard is down, and the grid already reserves the bottom safe area. The tracker also observes keyboardDidChangeFrameNotification so any consumer converting through a view that DID move mid-transition converges on settled geometry. Co-Authored-By: Claude Fable 5 --- ...orkspaceDetailView+TerminalArtifacts.swift | 11 +++++++++ .../MobileKeyboardFrameTracker.swift | 13 ++++++++++ .../MobileKeyboardFrameTrackerTests.swift | 24 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift index 6760beae3f78..ac253cdabdbb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView+TerminalArtifacts.swift @@ -104,6 +104,17 @@ extension WorkspaceDetailView { // avoidance; otherwise the view ALSO shrinks for the keyboard // and the reservation double-counts (extra gap when open). .ignoresSafeArea(.keyboard, edges: .bottom) + // The surface's frame must be KEYBOARD-INVARIANT: with only the keyboard + // inset ignored, the bottom edge respects the home indicator while the + // keyboard is down but extends to the window bottom while it is up + // (the keyboard region subsumes the indicator inset), so the frame + // breathed by the indicator height on every transition and every + // frame-relative keyboard conversion inside the surface was that much + // off mid-animation (the bars visibly popped by it after each rise — + // device forensics 2026-08-06). Extending under the indicator in ALL + // states pins the frame; the dock's required safe-area cap keeps the + // bars clear of the indicator while the keyboard is down. + .ignoresSafeArea(.container, edges: .bottom) // Keep the grid clear of the Dynamic Island and nav bar. .padding(.top, terminalTopPadding) } diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift index fecd2ad479e8..279814c1e39b 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -55,6 +55,19 @@ public final class MobileKeyboardFrameTracker { self?.latestTransition = transition } }, + // The settled twin of willChangeFrame: consumers converting the + // end frame through a view whose own frame moved during the + // transition re-derive against final geometry from this one. + notificationCenter.addObserver( + forName: UIResponder.keyboardDidChangeFrameNotification, + object: nil, + queue: .main + ) { [weak self] notification in + let transition = MobileKeyboardTransition(notification: notification) + MainActor.assumeIsolated { + self?.latestTransition = transition + } + }, notificationCenter.addObserver( forName: UIResponder.keyboardDidHideNotification, object: nil, diff --git a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift index 3b6f6a26045f..aa4afd609205 100644 --- a/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift +++ b/Packages/iOS/CmuxMobileSupport/Tests/CmuxMobileSupportTests/MobileKeyboardFrameTrackerTests.swift @@ -79,6 +79,30 @@ import UIKit #expect(tracker.overlap(in: view) == 0) } + @Test func didChangeFrameSupersedesTheWillChangeTransition() { + let center = NotificationCenter() + let tracker = MobileKeyboardFrameTracker(notificationCenter: center) + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 400, height: 800)) + let view = UIView(frame: window.bounds) + window.addSubview(view) + window.isHidden = false + defer { window.isHidden = true } + + post( + UIResponder.keyboardWillChangeFrameNotification, + endFrame: CGRect(x: 0, y: 534, width: 400, height: 266), + to: center + ) + post( + UIResponder.keyboardDidChangeFrameNotification, + endFrame: CGRect(x: 0, y: 500, width: 400, height: 300), + to: center + ) + + // The settled frame wins, so layout catch-ups converge on final geometry. + #expect(abs(tracker.overlap(in: view) - 300) <= 0.5) + } + @Test func didHideClearsTheTrackedTransition() { let center = NotificationCenter() let tracker = MobileKeyboardFrameTracker(notificationCenter: center) From 44c76ba2800be4dd7ed056f2de2303d699f96d68 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 14:40:11 -0700 Subject: [PATCH 10/14] iOS: drain the iPhone install queue without foreground fights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the phone reappears after being away, the queue drained every pending tag with a signed foreground launch, each suspending the previously launched app seconds after it paired — its stream dropped and the user found their dogfood app disconnected (2026-08-06 13:41-13:43: four tags launched back to back). Launch only the newest queued build per drain pass; older ones install fresh but stay unlaunched and re-pair with stored credentials on their next manual open. Co-Authored-By: Claude Fable 5 --- scripts/iphone-install-queue.sh | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/scripts/iphone-install-queue.sh b/scripts/iphone-install-queue.sh index 0631b7d3367d..a4264047f0c2 100755 --- a/scripts/iphone-install-queue.sh +++ b/scripts/iphone-install-queue.sh @@ -265,7 +265,7 @@ fail_entry() { # re-reads enqueued_at; when it changed, the newer build is left queued for the # next drain pass instead of being silently deleted or failed. drain_entry() { - local slug="$1" override_device="$2" + local slug="$1" override_device="$2" suppress_launch="${3:-0}" local entry="$PENDING_DIR/$slug" local meta="$entry/meta.json" local app="$entry/cmux.app" @@ -334,6 +334,18 @@ drain_entry() { return $? fi + # A multi-tag backlog must not fight over the phone's screen: each signed + # launch foregrounds its app, suspending the previously launched one seconds + # after it paired (its stream drops and the user finds it disconnected). + # The drain therefore launches ONLY the newest queued build; older ones are + # installed fresh but left unlaunched — they sign in and re-pair on their + # next manual open with their stored credentials. + if [[ "$suppress_launch" == "1" ]]; then + log "installed $bundle_id (launch deferred: a newer queued build owns the screen this drain)" + finish_installed + return $? + fi + if [[ "$no_setup" == "1" || "$no_sign_in" == "1" ]]; then if ! xcrun devicectl device process launch --terminate-existing \ --device "$device_id" "$bundle_id" >>"$LOGS_DIR/drain.log" 2>&1; then @@ -408,10 +420,22 @@ cmd_drain() { local start now installed_tags="" had_failure=0 start="$(date +%s)" while :; do - local slug rc remaining=0 + local slug rc remaining=0 newest_slug="" newest_stamp="" stamp suppress + # Only the most recently enqueued build gets the signed foreground launch + # this pass; see drain_entry's suppress_launch comment. Timestamps are + # ISO-8601 from this Mac's clock, so lexicographic comparison orders them. + for slug in $(pending_slugs); do + stamp="$(meta_field "$PENDING_DIR/$slug/meta.json" enqueued_at 2>/dev/null || true)" + if [[ -n "$stamp" && ( -z "$newest_stamp" || "$stamp" > "$newest_stamp" ) ]]; then + newest_stamp="$stamp" + newest_slug="$slug" + fi + done for slug in $(pending_slugs); do + suppress=0 + [[ -n "$newest_slug" && "$slug" != "$newest_slug" ]] && suppress=1 set +e - drain_entry "$slug" "$override_device" + drain_entry "$slug" "$override_device" "$suppress" rc=$? set -e case "$rc" in From 1af287d043271b08384c12139e59c5610af5ca25 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:41:59 -0700 Subject: [PATCH 11/14] iOS: regression test for the dock popping after the surface frame settles Device forensics: the host resizes the surface by the home-indicator height across every keyboard transition (bounds flipping 836<->802), so a floor seeded from a mid-transition view-space conversion lands 34pt shy and pops into place a second later. Red until the floor is window-anchored. Co-Authored-By: Claude Fable 5 --- ...GhosttySurfaceKeyboardDockFloorTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift index c242957829f5..6e3b2f12de2b 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -175,6 +175,37 @@ struct GhosttySurfaceKeyboardDockFloorTests { #expect(keyboardUp == 1) } + @Test("the floor holds the window-correct seat while the surface's own frame breathes") + func floorStaysWindowCorrectWhenTheSurfaceFrameChanges() throws { + let harness = try makeHarness(attached: false) + defer { tearDown(harness) } + + // Attach with the bottom edge respecting a 34pt indicator inset — the + // shape the host gives the surface while the keyboard is down. + harness.view.frame = CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight - 34) + harness.window.addSubview(harness.view) + harness.window.isHidden = false + harness.view.setNeedsLayout() + harness.view.layoutIfNeeded() + + // The keyboard rises while the frame is still short (mid-transition + // conversion territory: this is where the device build seeded a floor + // 34pt shy of the settled truth). + deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) + + // Safe-area propagation then extends the surface to the window bottom. + harness.view.frame = CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight) + harness.view.setNeedsLayout() + harness.view.layoutIfNeeded() + + // The bars must already sit at the window-correct keyboard top — the + // late +34 pop the dogfood recordings showed is exactly this assertion + // failing at the old view-anchored floor. + let dockBottom = Self.windowHeight - Self.keyboardHeight + let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) + #expect(abs(composerMaxY - dockBottom) <= 1) + } + @Test("a fresh toolbar is born in the keyboard-down state") func freshToolbarShowsTheShowKeyboardToggle() throws { let harness = try makeHarness() From fe83b254f86307f236b66be0e28a0b0f6f1cb17b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:42:00 -0700 Subject: [PATCH 12/14] iOS: anchor the keyboard floor to the window, not the surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface's frame is not keyboard-invariant and cannot be made so at the leaf: safe-area propagation resizes it by the home-indicator height across every keyboard transition (device bounds flipping 836<->802 even with the container-bottom ignore), so a floor expressed against the surface's bottom was seeded from a mid-transition conversion 34pt shy of the settled truth and popped into place when a later layout pass re-derived it. Anchor the floor constraint to the WINDOW — the only frame that never moves — installed per window attach and dropped on detach. Its constant is the notification's window-space overlap (MobileKeyboardTransition. overlapInWindow(of:)), final on first application, so the bars ride the keyboard's animation curve with no late correction. The viewport model converts the window floor into view space per layout pass, converging with the surface's settled frame. Co-Authored-By: Claude Fable 5 --- .../MobileKeyboardFrameTracker.swift | 7 ++ .../MobileKeyboardTransition.swift | 20 ++++ .../GhosttySurfaceView.swift | 92 +++++++++++++------ 3 files changed, 93 insertions(+), 26 deletions(-) diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift index 279814c1e39b..e954eb7fcd73 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardFrameTracker.swift @@ -99,6 +99,13 @@ public final class MobileKeyboardFrameTracker { latestTransition?.overlap(in: view) ?? 0 } + /// Returns how much of `view`'s window the tracked keyboard covers, in + /// window points — stable across a transition even when the view's own + /// frame animates with it. Zero while unknown or detached. + public func overlapInWindow(of view: UIView) -> CGFloat { + latestTransition?.overlapInWindow(of: view) ?? 0 + } + /// Returns whether the tracked keyboard is visible to `view` (including /// floating/split iPad keyboards that reserve no bottom space), or false /// while the keyboard state is unknown or the view is detached. diff --git a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift index d95307f173fc..75eb3801c0ae 100644 --- a/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift +++ b/Packages/iOS/CmuxMobileSupport/Sources/CmuxMobileSupport/MobileKeyboardTransition.swift @@ -46,6 +46,26 @@ public struct MobileKeyboardTransition: Sendable { ).height } + /// Returns how much of `view`'s WINDOW is covered by the keyboard's final + /// frame, in window points. + /// + /// Window-space overlap is stable across a keyboard transition even when + /// the view's own frame animates with it (safe-area propagation can resize + /// hosted views mid-transition), so constraints anchored to the window can + /// be seeded from the first notification without a late correction. + /// + /// - Parameter view: The view whose window should be measured. + /// - Returns: The bottom overlap in window coordinates, or zero when the + /// view is detached or the keyboard does not reach the window's bottom. + @MainActor public func overlapInWindow(of view: UIView) -> CGFloat { + guard let window = view.window else { return 0 } + let keyboardFrameInWindow = window.convert(endFrame, from: nil) + return MobileKeyboardReservation( + keyboardFrameInWindow: keyboardFrameInWindow, + viewFrameInWindow: window.bounds + ).height + } + /// Returns whether the keyboard is visible to `view`, including floating or /// split iPad keyboards that do not reserve bottom layout space. @MainActor public func isVisible(in view: UIView) -> Bool { diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index d4a9c0ee7ef1..97d56f3408c2 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -515,23 +515,25 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var composerHeightConstraint: NSLayoutConstraint? private var toolbarHeightConstraint: NSLayoutConstraint? /// Required upper bound on the composer's bottom edge derived from the - /// notification-tracked keyboard frame: `composer.bottom <= self.bottom - - /// keyboardOverlapFloor`. The keyboard guide equality above it is demoted one - /// priority notch, so when the guide misses a transition (a view attached - /// around a workspace switch can be left seated on the guide's safe-area - /// fallback while the keyboard is up) this floor still lifts the dock above - /// the real keyboard. While the guide tracks correctly both constraints agree - /// and the floor is inert. + /// notification-tracked keyboard frame: `composer.bottom <= window.bottom - + /// keyboardOverlapFloorInWindow`. Anchored to the WINDOW because the + /// surface's own frame animates with safe-area propagation during keyboard + /// transitions; see `refreshKeyboardFloorConstraintForWindow()`. The + /// keyboard guide equality above it is demoted one priority notch, so when + /// the guide misses or lags a transition this floor still lifts the dock + /// above the real keyboard; while the guide tracks correctly both + /// constraints agree and the floor is inert. private var composerBottomKeyboardFloorConstraint: NSLayoutConstraint? /// Process-wide keyboard-frame source for floor catch-up on attach/layout. /// Injected at construction (production callers take the shared tracker /// default) so tests pass a notification-center-isolated instance without /// touching the shared tracker other suites read. private let keyboardFrameTracker: MobileKeyboardFrameTracker - /// The keyboard overlap the floor constraint currently enforces, mirrored - /// into the viewport model so the terminal grid reserves the same space the - /// bars occupy. Zero while the keyboard is down or unknown. - private var keyboardOverlapFloor: CGFloat = 0 + /// The keyboard overlap the floor constraint currently enforces, in WINDOW + /// points (stable across the surface's own frame changes). The viewport + /// model converts it to view space per layout pass. Zero while the keyboard + /// is down or unknown. + private var keyboardOverlapFloorInWindow: CGFloat = 0 #if DEBUG private var keyboardHeightOverrideForTesting: CGFloat? private var composerBottomForTestingConstraint: NSLayoutConstraint? @@ -1023,7 +1025,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { + " dur=\(String(format: "%.2f", transition.duration))" + " trackerOverlap=\(Int(keyboardFrameTracker.overlap(in: self)))" + " guideTop=\(Int(keyboardLayoutGuide.layoutFrame.minY))" - + " floor=\(Int(keyboardOverlapFloor)) visible=\(willBeVisible ? 1 : 0)" + + " floorW=\(Int(keyboardOverlapFloorInWindow)) visible=\(willBeVisible ? 1 : 0)" ) #endif // The tracker (registered before any surface's handler) is the floor's @@ -1089,9 +1091,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // below can outrank a guide that missed the current keyboard transition; // see `composerBottomKeyboardFloorConstraint`. composerBottom.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 1) - let composerBottomFloor = composerContainer.bottomAnchor.constraint( - lessThanOrEqualTo: bottomAnchor - ) // With the guide in pure keyboard mode its keyboard-DOWN position is the // view's bottom edge, so the home-indicator seat is stated explicitly: // the dock may never sink into the bottom safe area. While the keyboard @@ -1103,7 +1102,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0) let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0) composerBottomToKeyboardConstraint = composerBottom - composerBottomKeyboardFloorConstraint = composerBottomFloor composerHeightConstraint = composerHeight self.toolbarHeightConstraint = toolbarHeight @@ -1111,7 +1109,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { composerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), composerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), composerBottom, - composerBottomFloor, composerBottomSafeAreaCap, composerHeight, dockedToolbar.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -1120,14 +1117,42 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { toolbarHeight, ]) layoutBottomDock() + // The keyboard floor is WINDOW-anchored and therefore installed per + // window attach; see `refreshKeyboardFloorConstraintForWindow()`. + } + + /// (Re)anchors the keyboard floor to the current window. + /// + /// The floor must be immune to the surface's own frame: safe-area + /// propagation resizes the hosted surface by the home-indicator height + /// across every keyboard transition (device forensics 2026-08-06, bounds + /// flipping 836<->802), so a floor expressed against the surface's bottom + /// was seeded with a mid-transition conversion and popped by that amount + /// after settle. The window's frame never moves, so a window-anchored + /// floor seeded from the notification's window-space overlap is final on + /// the first application. Removed on detach: a constraint into a window + /// the view has left would crash the next layout pass. + private func refreshKeyboardFloorConstraintForWindow() { + guard let window else { + composerBottomKeyboardFloorConstraint?.isActive = false + composerBottomKeyboardFloorConstraint = nil + return + } + if composerBottomKeyboardFloorConstraint?.secondItem === window { return } + composerBottomKeyboardFloorConstraint?.isActive = false + let floor = composerContainer.bottomAnchor.constraint( + lessThanOrEqualTo: window.bottomAnchor + ) + floor.constant = -keyboardOverlapFloorInWindow + floor.isActive = true + composerBottomKeyboardFloorConstraint = floor } /// Re-derives the keyboard floor and applies it to the floor constraint and /// the viewport model. /// - /// - Parameter overlap: The keyboard overlap to enforce, in this view's - /// coordinates; pass the notification transition's overlap on the - /// notification path, or the tracker-derived overlap on layout catch-up. + /// - Parameter overlap: The keyboard overlap to enforce, in WINDOW + /// coordinates (stable across the surface's own frame changes). /// - Returns: Whether the floor changed. @discardableResult private func applyKeyboardOverlapFloor(_ overlap: CGFloat) -> Bool { @@ -1135,8 +1160,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { guard keyboardHeightOverrideForTesting == nil else { return false } #endif let clamped = max(0, overlap) - guard abs(clamped - keyboardOverlapFloor) > 0.25 else { return false } - keyboardOverlapFloor = clamped + guard abs(clamped - keyboardOverlapFloorInWindow) > 0.25 else { return false } + keyboardOverlapFloorInWindow = clamped composerBottomKeyboardFloorConstraint?.constant = -clamped #if DEBUG // Paired with kb.willChange: a kb.floor long after its kb.willChange is @@ -1147,20 +1172,32 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return true } + /// The floor converted into this view's coordinates against its CURRENT + /// frame, for the viewport model. Re-derived per layout pass so it + /// converges with the surface's settled frame even when that frame + /// animated during the transition. + private var keyboardOverlapFloorInBounds: CGFloat { + guard keyboardOverlapFloorInWindow > 0, let window else { return 0 } + let viewMaxYInWindow = convert(bounds, to: window).maxY + let belowView = max(0, window.bounds.maxY - viewMaxYInWindow) + return max(0, keyboardOverlapFloorInWindow - belowView) + } + /// Catches the keyboard floor up from the process-wide tracker. /// /// A view attached to a window AFTER the keyboard came up never receives the /// keyboard notification for that transition, and UIKit's layout guide can /// stay on its safe-area fallback for the same reason. The tracker observed /// the transition process-wide, so every layout pass can re-derive the floor - /// for the view's current window position. + /// for the view's current window. /// /// - Returns: Whether the floor changed. @discardableResult private func synchronizeKeyboardFloorFromTracker() -> Bool { guard window != nil else { return false } + refreshKeyboardFloorConstraintForWindow() synchronizeKeyboardVisibilityFromTracker() - return applyKeyboardOverlapFloor(keyboardFrameTracker.overlap(in: self)) + return applyKeyboardOverlapFloor(keyboardFrameTracker.overlapInWindow(of: self)) } /// Reconciles the responder-facing visibility bit — and with it the @@ -1186,7 +1223,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { @discardableResult private func synchronizeKeyboardGeometryFromLayoutGuide() -> Bool { synchronizeKeyboardFloorFromTracker() - let nextHeight = max(keyboardOverlapFromLayoutGuide, keyboardOverlapFloor) + let nextHeight = max(keyboardOverlapFromLayoutGuide, keyboardOverlapFloorInBounds) guard abs(nextHeight - keyboardHeight) > 0.25 else { return false } keyboardHeight = nextHeight bottomDockTransitionObserved = bottomDockTransitionInFlight @@ -1237,7 +1274,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // The synthetic bottom equality replaces BOTH production keyboard // constraints; a live floor would make an override below it unsatisfiable. - keyboardOverlapFloor = 0 + keyboardOverlapFloorInWindow = 0 composerBottomKeyboardFloorConstraint?.constant = 0 composerBottomToKeyboardConstraint?.isActive = false if composerBottomForTestingConstraint == nil { @@ -2356,6 +2393,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { super.didMoveToWindow() MobileDebugLog.anchormux("surface.didMoveToWindow window=\(window != nil)") syncSurfaceVisibility() + // The keyboard floor is anchored to the window, so it must be + // (re)installed on attach and dropped on detach. + refreshKeyboardFloorConstraintForWindow() if window != nil { isDismantled = false setNeedsLayout() From 670b0dd06ce2585c8c28ec46907098c75b657d83 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:19:50 -0700 Subject: [PATCH 13/14] iOS: the bottom dock becomes a system keyboard accessory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rounds of custom dock geometry all failed the same way: some input lied mid-transition (keyboardLayoutGuide frozen or late on device, the surface's frame breathing by the home-indicator height, notification frames converted through that moving frame). Change the owner instead of the inputs: the toolbar row + composer band now live in one self-sizing UIInputView (KeyboardDockAccessoryView) that every cmux keyboard owner returns as its inputAccessoryView — the terminal input proxy while typing, and the surface itself (canBecomeFirstResponder) in the keyboard-down state, so the system docks the bar at the screen bottom and carries it on the keyboard's own animation in every transition, interactive gestures included. Deleted for the bars: the guide equality and configuration, the notification floor constraint, the safe-area cap, and all dock position math. The tracker now feeds only the terminal grid reservation, with the accessory's own footprint subtracted (keyboard end frames include the accessory, which would otherwise double-reserve the dock and make the docked-accessory state read as a visible keyboard). Dock tests rewritten to the accessory contract: responder wiring, chrome withholding, docked-state first responder, model derivation, and the glyph reconcile coverage carried over. Co-Authored-By: Claude Fable 5 --- .../GhosttySurfaceView.swift | 591 +++++++++--------- .../KeyboardDockAccessoryView.swift | 83 +++ .../TerminalInputTextView.swift | 37 +- ...GhosttySurfaceKeyboardDockFloorTests.swift | 166 +++-- 4 files changed, 478 insertions(+), 399 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 97d56f3408c2..62164ec5aac2 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -353,12 +353,22 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let pointValue: (CGFloat) -> String = { String(format: "%.3f", Double($0)) } - let toolbarFrame = dockedToolbar?.frame - let toolbarMinY = toolbarFrame.map { pointValue($0.minY) } ?? "none" - let toolbarMaxY = toolbarFrame.map { pointValue($0.maxY) } ?? "none" + // The dock lives in the keyboard's own window now, so its frames are + // reported CONVERTED into surface coordinates (where the bars sit over + // the terminal). While the accessory is unhosted (no responder holds + // the keyboard) the frames are unknowable: -1 sentinel. + let composerFrameInSurface = dockFrameInSurfaceCoordinates(of: composerContainer) + let toolbarFrameInSurface = dockedToolbar.flatMap { dockFrameInSurfaceCoordinates(of: $0) } + let composerMinY = composerFrameInSurface.map { pointValue($0.minY) } ?? pointValue(-1) + let composerMaxY = composerFrameInSurface.map { pointValue($0.maxY) } ?? pointValue(-1) + let toolbarMinY = toolbarFrameInSurface.map { pointValue($0.minY) } ?? "none" + let toolbarMaxY = toolbarFrameInSurface.map { pointValue($0.maxY) } ?? "none" let keyboardTransitionID = bottomDockTransitionInFlight ? 1 : -1 let keyboardTransitionTarget = pointValue(keyboardHeight) - let keyboardGuideTop = pointValue(keyboardLayoutGuide.layoutFrame.minY) + // Key kept so the log format stays parseable: now the accessory's top + // edge in surface coordinates, -1 while the accessory is unhosted. + let keyboardGuideTop = accessoryFrameInSurfaceCoordinates() + .map { pointValue($0.minY) } ?? pointValue(-1) return [ "chromeHidden=\(chromeHidden ? 1 : 0)", "composerActive=\(composerActive ? 1 : 0)", @@ -370,8 +380,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { "inputScene=\(inputScene)", "inputModal=\(inputModal)", "keyboardHeight=\(pointValue(keyboardHeight))", - "composerMinY=\(pointValue(composerContainer.frame.minY))", - "composerMaxY=\(pointValue(composerContainer.frame.maxY))", + "composerMinY=\(composerMinY)", + "composerMaxY=\(composerMaxY)", "toolbarMinY=\(toolbarMinY)", "toolbarMaxY=\(toolbarMaxY)", "bottomSafeArea=\(pointValue(safeAreaInsetsBottom))", @@ -507,36 +517,25 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { lastAppliedContainerSize = .zero } private var viewportCoordinator = TerminalViewportCoordinator() - /// The toolbar/composer use Auto Layout; the Ghostty renderer does not. This bit - /// keeps its display-link viewport updates alive until the guide-constrained - /// toolbar's presentation frame reaches its model frame. + /// The dock accessory animates in the keyboard's own window; the Ghostty + /// renderer does not follow Auto Layout. This bit keeps the display-link + /// viewport updates alive until the accessory's presentation frame reaches + /// its model frame. private var bottomDockTransitionObserved = false - private var composerBottomToKeyboardConstraint: NSLayoutConstraint? - private var composerHeightConstraint: NSLayoutConstraint? - private var toolbarHeightConstraint: NSLayoutConstraint? - /// Required upper bound on the composer's bottom edge derived from the - /// notification-tracked keyboard frame: `composer.bottom <= window.bottom - - /// keyboardOverlapFloorInWindow`. Anchored to the WINDOW because the - /// surface's own frame animates with safe-area propagation during keyboard - /// transitions; see `refreshKeyboardFloorConstraintForWindow()`. The - /// keyboard guide equality above it is demoted one priority notch, so when - /// the guide misses or lags a transition this floor still lifts the dock - /// above the real keyboard; while the guide tracks correctly both - /// constraints agree and the floor is inert. - private var composerBottomKeyboardFloorConstraint: NSLayoutConstraint? - /// Process-wide keyboard-frame source for floor catch-up on attach/layout. + /// Process-wide keyboard-frame source for model catch-up on attach/layout. /// Injected at construction (production callers take the shared tracker /// default) so tests pass a notification-center-isolated instance without /// touching the shared tracker other suites read. private let keyboardFrameTracker: MobileKeyboardFrameTracker - /// The keyboard overlap the floor constraint currently enforces, in WINDOW - /// points (stable across the surface's own frame changes). The viewport - /// model converts it to view space per layout pass. Zero while the keyboard - /// is down or unknown. + /// The notification-tracked keyboard overlap, in WINDOW points (stable + /// across the surface's own frame changes). The viewport model converts it + /// to view space per layout pass. Zero while the keyboard is down or + /// unknown. The dock's POSITION no longer derives from this — the OS + /// keyboard system places the accessory — so it feeds only the grid + /// reservation. private var keyboardOverlapFloorInWindow: CGFloat = 0 #if DEBUG private var keyboardHeightOverrideForTesting: CGFloat? - private var composerBottomForTestingConstraint: NSLayoutConstraint? #endif #if DEBUG @@ -741,6 +740,14 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { right: max(0, window.bounds.maxX - terminalFrame.maxX) ) } + inputProxy.keyboardAccessoryProvider = { [weak self] in + // The proxy rides the SAME dock accessory the surface returns, so + // the toolbar + composer band transfer seamlessly between the + // keyboard-up (proxy first responder) and keyboard-down (surface + // first responder) states. Withheld while the chrome is hidden. + guard let self, !self.chromeHidden else { return nil } + return self.keyboardDockAccessory + } return inputProxy }() @@ -790,10 +797,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { addSubview(debugAccessibilityProxy) addSubview(composerDockProbe) #endif - configureKeyboardLayoutGuide() - installPersistentToolbar() - installComposerContainer() - installBottomDockConstraints() + // Create the process-wide tracker no later than the first surface, so a + // keyboard transition that happens while THIS view is detached (workspace + // switch) is still observed and can seat the keyboard model on attach. + _ = MobileKeyboardFrameTracker.shared + installKeyboardDockAccessory() installArtifactChipContainer() initializeSurface() @@ -908,11 +916,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private var keyboardHeight: CGFloat = 0 private var keyboardVisible = false - /// Height the persistent bottom toolbar reserves in the terminal grid. The - /// toolbar is constrained to ``UIView/keyboardLayoutGuide`` and the viewport - /// coordinator consumes that same guide-derived overlap, so the grid must shrink - /// by this much to keep the bottom TUI rows visible above it. Zero until the - /// toolbar is installed (`installPersistentToolbar`). + /// Height the persistent toolbar row reserves in the terminal grid. The + /// row lives inside the OS-positioned dock accessory, so the grid must + /// shrink by this much to keep the bottom TUI rows visible above it. Zero + /// until the dock is installed (`installKeyboardDockAccessory`). private var reservedToolbarHeight: CGFloat = 0 /// Height of the docked accessory bar reserved in the grid geometry so the /// bottom TUI rows stay visible above it. Locked to the bar's actual button-row @@ -923,10 +930,26 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// toolbar's live top edge equal to the viewport edge; any whole-cell render /// remainder stays inside the terminal viewport instead of becoming toolbar fill. private static let persistentToolbarHeight: CGFloat = TerminalInputTextView.dockedButtonRowHeight - /// The docked accessory bar. Auto Layout pins it above the composer, whose bottom - /// is attached to ``UIView/keyboardLayoutGuide``. The viewport coordinator uses - /// the guide's same top edge for the terminal reservation. + /// The docked accessory bar (the toolbar row hosted inside + /// ``keyboardDockAccessory``). The viewport coordinator reserves its height + /// in the terminal grid; the OS keyboard system owns its position. private weak var dockedToolbar: UIView? + /// The system-positioned bottom dock: the toolbar row and the composer band + /// in one self-sizing `UIInputView`. Returned as the `inputAccessoryView` + /// of BOTH cmux keyboard owners — the terminal input proxy while typing + /// (the dock rides the keyboard's own animation) and this surface in the + /// keyboard-down state (the system docks it at the screen bottom) — so the + /// OS keyboard system owns the dock's POSITION in every state. + private var keyboardDockAccessory: KeyboardDockAccessoryView? + + /// The surface holds first responder whenever no cmux text responder owns + /// the keyboard, so the system keeps the dock accessory seated at the + /// screen bottom (the Messages pattern). + public override var canBecomeFirstResponder: Bool { true } + + public override var inputAccessoryView: UIView? { + chromeHidden ? nil : keyboardDockAccessory + } /// Whether the iMessage-style composer is currently open. The surface owns the /// whole bottom dock (terminal grid / toolbar / composer band / keyboard) in ONE /// coordinate system, so `composerActive` only drives the first-responder @@ -939,10 +962,10 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// The composer band: a surface-owned container the host installs the SwiftUI /// compose field into (via a `UIHostingController` in /// `GhosttySurfaceRepresentable`, which can see both layers; the terminal package - /// cannot import the UI package). Auto Layout pins it directly to - /// ``UIView/keyboardLayoutGuide`` (iMessage's field-nearest-keyboard layout), with - /// the docked toolbar riding its top edge and the terminal grid above that. The - /// viewport coordinator consumes the same guide overlap for the + /// cannot import the UI package). Hosted as the bottom slot of + /// ``keyboardDockAccessory`` (iMessage's field-nearest-keyboard layout), with + /// the toolbar row above it and the terminal grid above that. The viewport + /// coordinator reserves the same heights for the /// `terminal / toolbar / composer / keyboard` stack. private let composerContainer = UIView() /// Height (points) the open composer band reserves above the keyboard edge. Fed @@ -979,7 +1002,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // would leak keyboard state into concurrently running suites). @objc func handleKeyboardWillChangeFrame(_ notification: Notification) { guard let transition = MobileKeyboardTransition(notification: notification) else { return } + // Raised means more than the docked accessory: with the dock hosted as + // the inputAccessoryView, the keyboard-down state also posts frames + // covering just the accessory strip. let willBeVisible = transition.isVisible(in: self) + && trackedOverlapIsRaisedKeyboard(transition.overlapInWindow(of: self)) let wasVisible = keyboardVisible #if DEBUG // The composer-up/keyboard-down desync can be reached WITHOUT the dismiss @@ -1007,47 +1034,42 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // composer is dismissed only by its chevron or the toolbar composer button. // // This notification owns responder-facing VISIBILITY plus the keyboard - // FLOOR. UIKit's `keyboardLayoutGuide` still owns dock and viewport motion, - // including safe-area fallback and interrupted keyboard motion; the floor is - // an inequality bound, not a second equality authority, so a stale frame can - // never pin the dock below wherever the guide places it. It exists because - // the guide can miss a transition around window (re)attachment and stay on - // its safe-area fallback with the keyboard up; the floor then lifts the dock - // on the notification's own curve. + // overlap MODEL. The dock's POSITION is owned by the OS keyboard system + // (the dock is the responder's `inputAccessoryView` and rides in the + // keyboard's window), so nothing here moves bars; the grid reservation + // follows the tracker-derived overlap. updateDockedToolbarVisibility() #if DEBUG // Transition forensics for the bars-lag-the-keyboard class: one line per - // willChangeFrame with the notification frame, the tracker/guide/floor - // trio, and the animation duration, so a dogfood recording can be lined - // up against which source moved (or failed to move) the dock and when. + // willChangeFrame with the notification frame, the tracker/floor pair, + // and the animation duration, so a dogfood recording can be lined up + // against which source moved (or failed to move) the grid and when. MobileDebugLog.anchormux( "kb.willChange endMinY=\(Int(transition.endFrame.minY)) endH=\(Int(transition.endFrame.height))" + " dur=\(String(format: "%.2f", transition.duration))" + " trackerOverlap=\(Int(keyboardFrameTracker.overlap(in: self)))" - + " guideTop=\(Int(keyboardLayoutGuide.layoutFrame.minY))" + " floorW=\(Int(keyboardOverlapFloorInWindow)) visible=\(willBeVisible ? 1 : 0)" ) #endif - // The tracker (registered before any surface's handler) is the floor's - // ONLY data source; this handler merely re-reads it on the keyboard's - // own animation curve so a floor change moves like the keyboard. A - // second per-view derivation here could disagree with the tracker and - // be undone by the next layout catch-up. The renderer follows through - // the display-link transition pass, exactly as for guide-driven motion. + // The tracker (registered before any surface's handler) is the overlap + // model's ONLY data source; this handler merely re-reads it on the + // keyboard's own animation curve so the grid target updates in step + // with the keyboard. There are no dock constraints left to animate — + // the keyboard window carries the accessory — so the renderer follows + // through the display-link transition pass and the layout below. transition.animate { [weak self] in guard let self else { return } - if self.synchronizeKeyboardFloorFromTracker() { - self.layoutIfNeeded() - } + self.synchronizeKeyboardFloorFromTracker() } + layoutRenderedTerminalForCurrentViewport() setNeedsLayout() } - /// Keep the renderer clipped to UIKit's live keyboard guide during animation. + /// Keep the renderer tracking the live dock during keyboard animation. /// - /// The constrained toolbar/composer already follow the guide automatically. The - /// terminal renderer is not Auto Layout-backed, so its display-link pass reads the - /// constrained toolbar's presentation frame until it reaches the model target. + /// The dock accessory animates in the keyboard's own window. The terminal + /// renderer is not Auto Layout-backed, so its display-link pass reads the + /// accessory's presentation frame until it reaches the model target. private func advanceBottomDockTransition() { let isTransitioning = bottomDockTransitionInFlight guard isTransitioning || bottomDockTransitionObserved else { return } @@ -1059,101 +1081,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Installs the system keyboard guide as the primary keyboard geometry source. - private func configureKeyboardLayoutGuide() { - keyboardLayoutGuide.followsUndockedKeyboard = false - // Pure keyboard tracking. With `usesBottomSafeArea = true` the guide's - // position is coupled to bottom-safe-area propagation, which lands at - // the END of a keyboard transition: on device the guide-constrained - // bars sat still while the keyboard rose over them, then snapped up - // after it settled (dogfood recording, 2026-08-05), and on the - // simulator the settled guide read the bottom inset (~34pt) below the - // notification frame. The keyboard-down seat above the home indicator - // that `usesBottomSafeArea` provided is expressed explicitly in - // `installBottomDockConstraints()` via a required safe-area cap. - keyboardLayoutGuide.usesBottomSafeArea = false - // Create the process-wide tracker no later than the first surface, so a - // keyboard transition that happens while THIS view is detached (workspace - // switch) is still observed and can seat the keyboard floor on attach. - _ = MobileKeyboardFrameTracker.shared - } - - /// Pins the whole dock stack to Apple's keyboard guide. - private func installBottomDockConstraints() { - guard let dockedToolbar else { return } - dockedToolbar.translatesAutoresizingMaskIntoConstraints = false - composerContainer.translatesAutoresizingMaskIntoConstraints = false - - let composerBottom = composerContainer.bottomAnchor.constraint( - equalTo: keyboardLayoutGuide.topAnchor - ) - // One notch below required so the notification-derived keyboard floor - // below can outrank a guide that missed the current keyboard transition; - // see `composerBottomKeyboardFloorConstraint`. - composerBottom.priority = UILayoutPriority(rawValue: UILayoutPriority.required.rawValue - 1) - // With the guide in pure keyboard mode its keyboard-DOWN position is the - // view's bottom edge, so the home-indicator seat is stated explicitly: - // the dock may never sink into the bottom safe area. While the keyboard - // is up, the guide equality and the keyboard floor are both stricter, so - // this cap only decides the keyboard-down seat. - let composerBottomSafeAreaCap = composerContainer.bottomAnchor.constraint( - lessThanOrEqualTo: safeAreaLayoutGuide.bottomAnchor - ) - let composerHeight = composerContainer.heightAnchor.constraint(equalToConstant: 0) - let toolbarHeight = dockedToolbar.heightAnchor.constraint(equalToConstant: 0) - composerBottomToKeyboardConstraint = composerBottom - composerHeightConstraint = composerHeight - self.toolbarHeightConstraint = toolbarHeight - - NSLayoutConstraint.activate([ - composerContainer.leadingAnchor.constraint(equalTo: leadingAnchor), - composerContainer.trailingAnchor.constraint(equalTo: trailingAnchor), - composerBottom, - composerBottomSafeAreaCap, - composerHeight, - dockedToolbar.leadingAnchor.constraint(equalTo: leadingAnchor), - dockedToolbar.trailingAnchor.constraint(equalTo: trailingAnchor), - dockedToolbar.bottomAnchor.constraint(equalTo: composerContainer.topAnchor), - toolbarHeight, - ]) - layoutBottomDock() - // The keyboard floor is WINDOW-anchored and therefore installed per - // window attach; see `refreshKeyboardFloorConstraintForWindow()`. - } - - /// (Re)anchors the keyboard floor to the current window. - /// - /// The floor must be immune to the surface's own frame: safe-area - /// propagation resizes the hosted surface by the home-indicator height - /// across every keyboard transition (device forensics 2026-08-06, bounds - /// flipping 836<->802), so a floor expressed against the surface's bottom - /// was seeded with a mid-transition conversion and popped by that amount - /// after settle. The window's frame never moves, so a window-anchored - /// floor seeded from the notification's window-space overlap is final on - /// the first application. Removed on detach: a constraint into a window - /// the view has left would crash the next layout pass. - private func refreshKeyboardFloorConstraintForWindow() { - guard let window else { - composerBottomKeyboardFloorConstraint?.isActive = false - composerBottomKeyboardFloorConstraint = nil - return - } - if composerBottomKeyboardFloorConstraint?.secondItem === window { return } - composerBottomKeyboardFloorConstraint?.isActive = false - let floor = composerContainer.bottomAnchor.constraint( - lessThanOrEqualTo: window.bottomAnchor - ) - floor.constant = -keyboardOverlapFloorInWindow - floor.isActive = true - composerBottomKeyboardFloorConstraint = floor - } - - /// Re-derives the keyboard floor and applies it to the floor constraint and - /// the viewport model. + /// Re-derives the tracked keyboard overlap and applies it to the viewport + /// model. /// - /// - Parameter overlap: The keyboard overlap to enforce, in WINDOW + /// - Parameter overlap: The keyboard overlap to record, in WINDOW /// coordinates (stable across the surface's own frame changes). - /// - Returns: Whether the floor changed. + /// - Returns: Whether the overlap changed. @discardableResult private func applyKeyboardOverlapFloor(_ overlap: CGFloat) -> Bool { #if DEBUG @@ -1162,40 +1095,62 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let clamped = max(0, overlap) guard abs(clamped - keyboardOverlapFloorInWindow) > 0.25 else { return false } keyboardOverlapFloorInWindow = clamped - composerBottomKeyboardFloorConstraint?.constant = -clamped #if DEBUG // Paired with kb.willChange: a kb.floor long after its kb.willChange is - // the late-snap signature (floor applied by layout catch-up, unanimated). + // the late-snap signature (overlap applied by layout catch-up). MobileDebugLog.anchormux("kb.floor \(Int(clamped))") #endif setNeedsGeometrySync() return true } - /// The floor converted into this view's coordinates against its CURRENT - /// frame, for the viewport model. Re-derived per layout pass so it - /// converges with the surface's settled frame even when that frame - /// animated during the transition. + /// The dock accessory's own footprint inside a tracked "keyboard" frame. + /// + /// UIKit's keyboard end frames INCLUDE the input accessory: with the dock + /// accessory-hosted, a raised keyboard's frame contains the dock's content + /// height, and the keyboard-DOWN docked state posts frames covering just + /// the accessory (content + home-indicator inset). Both the grid model and + /// the visibility bit must subtract this footprint or the dock double + /// counts and the docked state reads as a visible keyboard. + private var accessoryDockedFootprintInWindow: CGFloat { + guard let accessory = keyboardDockAccessory, !chromeHidden else { return 0 } + return accessory.contentHeight + (window?.safeAreaInsets.bottom ?? 0) + } + + /// Whether the tracked overlap represents a genuinely raised keyboard, as + /// opposed to the docked accessory alone. + private func trackedOverlapIsRaisedKeyboard(_ overlapInWindow: CGFloat) -> Bool { + overlapInWindow > accessoryDockedFootprintInWindow + 8 + } + + /// The KEYBOARD-ONLY portion of the tracked overlap converted into this + /// view's coordinates against its CURRENT frame, for the viewport model. + /// Re-derived per layout pass so it converges with the surface's settled + /// frame even when that frame animated during the transition. The + /// accessory's own height is subtracted (the grid reserves the dock + /// separately via `reservedToolbarHeight` + `composerBandHeight`). private var keyboardOverlapFloorInBounds: CGFloat { guard keyboardOverlapFloorInWindow > 0, let window else { return 0 } + guard trackedOverlapIsRaisedKeyboard(keyboardOverlapFloorInWindow) else { return 0 } + let keyboardOnly = max( + 0, keyboardOverlapFloorInWindow - (keyboardDockAccessory?.contentHeight ?? 0) + ) let viewMaxYInWindow = convert(bounds, to: window).maxY let belowView = max(0, window.bounds.maxY - viewMaxYInWindow) - return max(0, keyboardOverlapFloorInWindow - belowView) + return max(0, keyboardOnly - belowView) } - /// Catches the keyboard floor up from the process-wide tracker. + /// Catches the keyboard overlap model up from the process-wide tracker. /// - /// A view attached to a window AFTER the keyboard came up never receives the - /// keyboard notification for that transition, and UIKit's layout guide can - /// stay on its safe-area fallback for the same reason. The tracker observed - /// the transition process-wide, so every layout pass can re-derive the floor + /// A view attached to a window AFTER the keyboard came up never receives + /// the keyboard notification for that transition. The tracker observed the + /// transition process-wide, so every layout pass can re-derive the overlap /// for the view's current window. /// - /// - Returns: Whether the floor changed. + /// - Returns: Whether the overlap changed. @discardableResult private func synchronizeKeyboardFloorFromTracker() -> Bool { guard window != nil else { return false } - refreshKeyboardFloorConstraintForWindow() synchronizeKeyboardVisibilityFromTracker() return applyKeyboardOverlapFloor(keyboardFrameTracker.overlapInWindow(of: self)) } @@ -1210,20 +1165,28 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// next real transition. Change-guarded so the glyph cross-dissolve only /// runs on actual flips, not every layout pass. private func synchronizeKeyboardVisibilityFromTracker() { - let visible = keyboardFrameTracker.isVisible(in: self) + // Raised means more than the docked accessory: the accessory alone + // (keyboard down, surface first responder) also posts keyboard frames. + let visible = trackedOverlapIsRaisedKeyboard( + keyboardFrameTracker.overlapInWindow(of: self) + ) guard visible != keyboardVisible else { return } keyboardVisible = visible inputProxy.setKeyboardShown(visible) } - /// Updates the renderer's overlap model from the guide's target top edge, - /// bounded below by the notification-derived keyboard floor so the grid - /// reserves the same space the floor-lifted bars occupy when the guide - /// missed the current transition. + /// Updates the renderer's overlap model from the notification-tracked, + /// window-anchored keyboard overlap, converted into this view's bounds per + /// pass so it converges with the surface's settled frame. @discardableResult - private func synchronizeKeyboardGeometryFromLayoutGuide() -> Bool { + private func synchronizeKeyboardGeometry() -> Bool { + #if DEBUG + // The synthetic override writes `keyboardHeight` directly; the tracked + // overlap (forced to zero under the override) must not stomp it. + guard keyboardHeightOverrideForTesting == nil else { return false } + #endif synchronizeKeyboardFloorFromTracker() - let nextHeight = max(keyboardOverlapFromLayoutGuide, keyboardOverlapFloorInBounds) + let nextHeight = keyboardOverlapFloorInBounds guard abs(nextHeight - keyboardHeight) > 0.25 else { return false } keyboardHeight = nextHeight bottomDockTransitionObserved = bottomDockTransitionInFlight @@ -1231,62 +1194,72 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { return true } - /// Keyboard overlap represented by the system guide, excluding its safe-area fallback. - private var keyboardOverlapFromLayoutGuide: CGFloat { - #if DEBUG - if let keyboardHeightOverrideForTesting { - return max(0, keyboardHeightOverrideForTesting) - } - #endif - guard window != nil, bounds.height > 0 else { return 0 } - let guideFrame = keyboardLayoutGuide.layoutFrame - // A guide frame is usable only after UIKit has seated it against this view's - // bottom edge. During first attachment or rotation, keep the prior overlap for - // that transient pass instead of interpreting CGRect.zero as a full-screen - // keyboard. - guard abs(guideFrame.maxY - bounds.maxY) <= 1 else { return keyboardHeight } - let guideTop = min(max(0, guideFrame.minY), bounds.maxY) - let occupancy = max(0, bounds.maxY - guideTop) - return occupancy > safeAreaInsetsBottom + 0.5 ? occupancy : 0 - } - - /// Whether UIKit is still animating the guide-constrained dock toward its target. + /// Whether the OS is still animating the dock accessory toward its target. + /// + /// The accessory animates in the keyboard's own window, so its presentation + /// frame is compared against its model frame after converting both into + /// this view's coordinates. False when the accessory (or either window) is + /// unavailable, or when no presentation animation is in flight. private var bottomDockTransitionInFlight: Bool { #if DEBUG if keyboardHeightOverrideForTesting != nil { return false } #endif guard dockedToolbarShouldBeVisible, - let dockedToolbar, - !dockedToolbar.isHidden, - let presentationFrame = dockedToolbar.layer.presentation()?.frame else { + let model = accessoryFrameInSurfaceCoordinates(), + let presented = accessoryPresentationFrameInSurfaceCoordinates() else { return false } - return abs(presentationFrame.minY - dockedToolbar.frame.minY) > 0.5 + return abs(presented.minY - model.minY) > 0.5 + } + + /// Converts a dock rect (in `superview`'s coordinates, inside the keyboard + /// window) into this view's coordinates, routing explicitly through both + /// windows because the accessory is not in this view's hierarchy. + private func convertDockRect(_ rect: CGRect, from superview: UIView) -> CGRect? { + guard let accessoryWindow = keyboardDockAccessory?.window, let window else { return nil } + let inAccessoryWindow = superview.convert(rect, to: accessoryWindow) + let inOwnWindow = accessoryWindow.convert(inAccessoryWindow, to: window) + return convert(inOwnWindow, from: window) + } + + /// The accessory's model frame in this view's coordinates, or nil while the + /// accessory (or either window) is unavailable. + private func accessoryFrameInSurfaceCoordinates() -> CGRect? { + guard let accessory = keyboardDockAccessory, + let superview = accessory.superview else { return nil } + return convertDockRect(accessory.frame, from: superview) + } + + /// The accessory's live presentation frame in this view's coordinates, or + /// nil while it is unavailable or no animation has produced a presentation + /// layer. + private func accessoryPresentationFrameInSurfaceCoordinates() -> CGRect? { + guard let accessory = keyboardDockAccessory, + let superview = accessory.superview, + let presentationFrame = accessory.layer.presentation()?.frame else { return nil } + return convertDockRect(presentationFrame, from: superview) + } + + /// A dock content view's frame (toolbar row or composer band, hosted inside + /// the accessory) converted into this view's coordinates, or nil while the + /// accessory is unhosted. + private func dockFrameInSurfaceCoordinates(of view: UIView) -> CGRect? { + guard let superview = view.superview else { return nil } + return convertDockRect(view.frame, from: superview) } #if DEBUG - /// Switches the dock to a synthetic bottom anchor for host tests and previews. + /// Forces a synthetic keyboard overlap for host tests and previews. Only + /// the model/grid is synthetic now — the dock accessory needs no bottom + /// anchor because the OS positions it. private func setKeyboardHeightOverrideForTesting(_ height: CGFloat) { let clamped = max(0, height) keyboardHeightOverrideForTesting = clamped keyboardHeight = clamped bottomDockTransitionObserved = false - - // The synthetic bottom equality replaces BOTH production keyboard - // constraints; a live floor would make an override below it unsatisfiable. + // A live tracked overlap would fight the override on the next + // geometry pass; zero it while the override is authoritative. keyboardOverlapFloorInWindow = 0 - composerBottomKeyboardFloorConstraint?.constant = 0 - composerBottomToKeyboardConstraint?.isActive = false - if composerBottomForTestingConstraint == nil { - composerBottomForTestingConstraint = composerContainer.bottomAnchor.constraint( - equalTo: bottomAnchor - ) - } - composerBottomForTestingConstraint?.constant = -TerminalLetterboxGeometry.keyboardOccupancy( - keyboardHeight: clamped, - bottomSafeAreaInset: safeAreaInsetsBottom - ) - composerBottomForTestingConstraint?.isActive = true } #endif @@ -1320,29 +1293,29 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { #endif - /// Dock the accessory bar as a persistent bottom toolbar. Auto Layout pins it - /// through the composer container to ``UIView/keyboardLayoutGuide``; the viewport - /// coordinator consumes the same guide-derived overlap for the terminal grid. - private func installPersistentToolbar() { + /// Builds the keyboard dock accessory around the accessory toolbar row and + /// the composer band. The dock is NOT a subview of this surface: it lives + /// in the keyboard's own window whenever a cmux responder (the input + /// proxy, the composer field, or this surface itself) is first responder, + /// so the OS keyboard system positions it in every state and no zPosition + /// juggling against the Ghostty render layer is needed. Neither content + /// view clips its bounds: the dock's Liquid-Glass controls lift past the + /// band edge on drag. + private func installKeyboardDockAccessory() { let toolbar = inputProxy.toolbarView - addSubview(toolbar) dockedToolbar = toolbar - // Raise the toolbar above the Ghostty renderer's own sublayer (which it - // inserts directly into `self.layer`), so a dragged/lifted Liquid-Glass button - // floating UP over the terminal is not occluded or clipped by the render layer - // (item 6). Subview order alone does not guarantee this because the renderer - // sublayer is composited at the layer level; the zoom overlay uses the same - // `zPosition` lever. The toolbar must also not clip its own bounds so the lift - // is visible above the strip. - toolbar.layer.zPosition = Self.bottomChromeZPosition toolbar.clipsToBounds = false + composerContainer.backgroundColor = .clear + composerContainer.isHidden = true + composerContainer.clipsToBounds = false + keyboardDockAccessory = KeyboardDockAccessoryView( + toolbar: toolbar, + composer: composerContainer, + toolbarRowHeight: Self.persistentToolbarHeight + ) updateDockedToolbarVisibility() } - /// Layer `zPosition` for the bottom chrome (toolbar + composer band), placing it - /// above the Ghostty renderer's sublayer so a lifted Liquid-Glass button is not - /// clipped by the terminal render bounds (item 6). Below the zoom HUD (1100). - private static let bottomChromeZPosition: CGFloat = 1000 /// Floats above dock chrome, terminal content, AND the verified-replay /// frozen presentation (zPosition 2000). The freeze copies only renderer /// pixels, so anything below it blinks out for the length of every @@ -1415,8 +1388,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { bottomSafeAreaInset: safeAreaInsetsBottom, chromeHidden: chromeHidden, chromeVisible: dockedToolbarShouldBeVisible && dockedToolbar?.isHidden == false, - toolbarFrame: dockedToolbar?.frame, - toolbarPresentationFrame: dockedToolbar?.layer.presentation()?.frame, + // The dock lives in the keyboard's window; hand the coordinator its + // frame converted into SURFACE coordinates (model and live + // presentation respectively), nil while the accessory is unhosted. + toolbarFrame: accessoryFrameInSurfaceCoordinates(), + toolbarPresentationFrame: accessoryPresentationFrameInSurfaceCoordinates(), viewportNegotiationUnsettled: bottomDockTransitionInFlight || pendingViewportReport != nil || awaitingViewportEcho @@ -1495,10 +1471,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { dockedToolbar?.isHidden = !shouldShow // The composer band rides with the toolbar: hide it when the chrome is // suppressed, show it again when the chrome returns and a field is mounted. - // Its height constraint already collapses to zero while hidden; toggling - // `isHidden` also stops it intercepting taps. + // Both flags hide CONTENT inside the accessory; toggling `isHidden` also + // stops the band intercepting taps. composerContainer.isHidden = !shouldShow || composerContainer.subviews.isEmpty reservedToolbarHeight = reserved + // A chrome toggle changes what `inputAccessoryView` returns, and the + // system re-reads it only on a reload. Reload both candidate first + // responders — cheap and safe when either is not first responder. + reloadInputViews() + inputProxy.reloadInputViews() layoutRenderedTerminalForCurrentViewport() updateArtifactChipVisibility(animated: true) setNeedsGeometrySync() @@ -1508,38 +1489,42 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// Temporarily hide (or re-show) the bottom chrome — the always-visible toolbar /// and any open composer band — via the HIDE button (item 2). /// - /// Hiding also drops the software keyboard: with the toolbar always visible, HIDE - /// only makes sense as "clear all chrome to see the full terminal", which requires - /// resigning the keyboard too. `isComposerPresented` is left untouched, so the - /// composer (and its draft) reappear intact on the next terminal tap - /// (``handleTap``). UIKit animates keyboard movement through its layout guide; - /// ``animateBottomDock`` handles only the chrome-height change. - private func setChromeHidden(_ hidden: Bool) { + /// Hiding also drops the software keyboard AND the surface's own + /// first-responder seat: with the dock hosted by the keyboard system, HIDE + /// means "no responder offers the accessory", so the typing responder is + /// resigned, the surface resigns, and the input views reload with a nil + /// accessory. `isComposerPresented` is left untouched, so the composer (and + /// its draft) reappear intact on the next terminal tap (``handleTap``), + /// which un-hides and re-seats the surface as first responder. + /// Internal (not private) as the chrome test seam. + func setChromeHidden(_ hidden: Bool) { guard chromeHidden != hidden else { return } chromeHidden = hidden - if hidden, keyboardVisible { - // Drop the keyboard first; its layout guide re-seats the dock while the - // visibility update below removes the toolbar/composer. Resign - // whichever responder actually owns the keyboard — the band can be - // presented while the terminal's hidden input proxy (a sibling of - // `composerContainer`) holds first responder, so gating on - // `composerActive` alone would leave the keyboard up while the chrome - // hides. - resignCurrentInput() + if hidden { + if keyboardVisible { + // Drop the keyboard first. Resign whichever responder actually + // owns it — the band can be presented while the terminal's + // hidden input proxy holds first responder, so gating on + // `composerActive` alone would leave the keyboard up while the + // chrome hides. + resignCurrentInput() + } + // Give up the keyboard-down seat too, so no responder offers the + // dock accessory while the chrome is hidden. + resignFirstResponder() + reloadInputViews() } updateDockedToolbarVisibility() if hidden { - // Hide: animate the dock collapsing down into the bottom edge. (The toolbar - // is set `isHidden` only after this animation by `updateDockedToolbarVisibility` - // — actually `isHidden` is set synchronously, so this animate-out is largely - // invisible, but it keeps the frame coherent for the next show.) + // Hide: animate the grid reclaiming the dock's reservation. animateBottomDock() } else { - // Show: snap real frames into place with the bar visible, then let the - // ``handleTap``-driven `focusInput()` → keyboard-show animation carry the - // motion. Animating here from the collapsed bottom-edge strip would - // double-animate against the keyboard rise. + // Show: snap real frames into place with the bar visible, re-take + // the keyboard-down seat so the system docks the accessory, then + // let any ``handleTap``-driven `focusInput()` → keyboard-show + // animation carry further motion. layoutBottomDock() + becomeFirstResponder() } setNeedsGeometrySync() } @@ -1696,24 +1681,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { } } - /// Install the composer band container into the surface's view hierarchy, above - /// the docked toolbar. Hidden and zero-height until the host mounts a compose - /// field into it (``mountComposerView(_:)``); the surface positions it in - /// ``layoutBottomDock()`` and reserves its height in the grid. Auto Layout pins its - /// bottom to ``UIView/keyboardLayoutGuide`` and pins the toolbar above it, so UIKit - /// and the viewport coordinator share one keyboard edge. - private func installComposerContainer() { - composerContainer.backgroundColor = .clear - composerContainer.isHidden = true - // Do NOT clip: the composer's Liquid-Glass controls lift/shadow past the band - // edge, and the band must sit above the Ghostty render layer (item 6) so the - // glass is not clipped by the terminal bounds. Raised to the same chrome - // z-position as the toolbar. - composerContainer.clipsToBounds = false - composerContainer.layer.zPosition = Self.bottomChromeZPosition - addSubview(composerContainer) - } - /// Mounts the host-built artifact chip inside the terminal's bottom-dock /// coordinate system, or hides it when `view` is `nil`. /// @@ -1859,8 +1826,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// reports its measured height via ``setComposerBandHeight(_:animated:)``. /// /// The mounted view is pinned edge-to-edge inside the band with Auto Layout, so it - /// fills the guide-pinned band — there is no second keyboard layout system fighting - /// the surface for the band's frame. + /// fills the accessory-hosted band — there is no second keyboard layout system + /// fighting the OS-positioned dock for the band's frame. public func mountComposerView(_ view: UIView?) { composerContainer.subviews.forEach { $0.removeFromSuperview() } guard let view else { @@ -1884,8 +1851,8 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { /// toolbar, from the hosted compose field's intrinsic content size. Drives the /// grid reservation (so a field-grow pushes only the terminal up) and the dock /// layout. When `animated`, the reservation + reflow run inside a `UIView.animate`; - /// keyboard movement itself remains owned by ``UIView/keyboardLayoutGuide``. - /// Idempotent: a no-op when the height is + /// keyboard movement itself remains owned by the OS keyboard system, which + /// carries the dock accessory. Idempotent: a no-op when the height is /// unchanged (then `completion` runs immediately so an unmount-on-close never /// strands the mounted field). /// @@ -1909,7 +1876,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { let apply = { [weak self] in guard let self else { return } self.layoutRenderedTerminalForCurrentViewport() + // Forward the band height into the accessory INSIDE the animation + // closure so the accessory's constraint change (and its self-sizing) + // animates with the reflow. self.layoutBottomDock() + self.keyboardDockAccessory?.layoutIfNeeded() self.layoutIfNeeded() } if animated { @@ -1929,12 +1900,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { layoutBottomDock(using: viewportSnapshot()) } + /// Positions nothing: the OS keyboard system owns the dock's placement. + /// Only the composer band HEIGHT is forwarded into the accessory (which + /// self-sizes around it), plus the surface-owned artifact chip layout. private func layoutBottomDock(using snapshot: TerminalViewportSnapshot) { - composerHeightConstraint?.constant = snapshot.composerFrame.height - toolbarHeightConstraint?.constant = min( - reservedToolbarHeight, - snapshot.toolbarFrame.height - ) + keyboardDockAccessory?.setComposerBandHeight(snapshot.composerFrame.height) layoutArtifactChip(using: snapshot) } @@ -2347,7 +2317,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { public override func layoutSubviews() { super.layoutSubviews() - synchronizeKeyboardGeometryFromLayoutGuide() + synchronizeKeyboardGeometry() let snapshot = viewportSnapshot() layoutBottomDock(using: snapshot) layoutRenderedTerminalForCurrentViewport(using: snapshot) @@ -2382,7 +2352,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setKeyboardHeightOverrideForTesting(keyboardHeightOverrideForTesting) } #endif - synchronizeKeyboardGeometryFromLayoutGuide() + synchronizeKeyboardGeometry() let snapshot = viewportSnapshot() layoutBottomDock(using: snapshot) layoutRenderedTerminalForCurrentViewport(using: snapshot) @@ -2393,9 +2363,6 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { super.didMoveToWindow() MobileDebugLog.anchormux("surface.didMoveToWindow window=\(window != nil)") syncSurfaceVisibility() - // The keyboard floor is anchored to the window, so it must be - // (re)installed on attach and dropped on detach. - refreshKeyboardFloorConstraintForWindow() if window != nil { isDismantled = false setNeedsLayout() @@ -2411,6 +2378,11 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { setFocus(true) if autoFocusOnWindowAttach, UIApplication.shared.applicationState == .active { focusInput() + } else if !chromeHidden { + // Keyboard-down docking: no cmux text responder owns the + // keyboard, so the SURFACE takes first responder and the system + // seats the dock accessory at the screen bottom. + becomeFirstResponder() } resetVisibleArtifactCountTracking() startDisplayLink() @@ -2854,13 +2826,21 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { public func resignCurrentInput() { synchronizeActualInputOwner() inputSession.send(.releaseFocus) + // Keyboard-down docking: with the typing responder released, the + // SURFACE takes first responder (while attached and the chrome is + // visible) so the dock accessory stays seated at the screen bottom + // instead of leaving with the keyboard. + if window != nil, !chromeHidden { + becomeFirstResponder() + } } /// Resigns this surface's hidden text input. public func resignInput() { resignCurrentInput() - // Geometry follows `keyboardLayoutGuide`; responder release only decides - // whether UIKit should dismiss the software keyboard. + // The dock accessory is OS-positioned; responder release only decides + // whether UIKit dismisses the software keyboard (the surface re-takes + // first responder above so the dock docks instead of disappearing). } /// Stops user-visible and accessibility output from a surface SwiftUI has removed. @@ -2902,6 +2882,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { renderInFlightSince = nil needsAnotherRender = false inputSession.send(.surfaceDetached) + // Drop the keyboard-down docking seat; UIKit reclaims the accessory's + // input view on resign, so the dock needs no manual removal. + resignFirstResponder() bottomDockTransitionObserved = false stopDisplayLink() setFocus(false) @@ -2923,6 +2906,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { runtime?.tick() } + #if DEBUG + /// Test seam: the hidden typing responder, so tests can assert it vends + /// the same dock accessory the surface does. + var inputProxyForTesting: TerminalInputTextView { inputProxy } + #endif + func simulateInputProxyTextChangeForTesting(_ text: String, isComposing: Bool) { setFocus(true) inputProxy.simulateTextChangeForTesting(text, isComposing: isComposing) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift new file mode 100644 index 000000000000..f62dd6b7f350 --- /dev/null +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/KeyboardDockAccessoryView.swift @@ -0,0 +1,83 @@ +#if canImport(UIKit) +import UIKit + +/// The bottom dock (accessory toolbar row + composer band) as a self-sizing +/// keyboard accessory. +/// +/// The system positions this view: while a text responder owns the keyboard it +/// rides the keyboard's own animation (rises, dismissals, and interactive +/// gestures included), and while the surface itself is first responder — the +/// keyboard-down state — the system docks it at the screen bottom. Ownership by +/// the keyboard window replaces the previous constraint stack (layout-guide +/// equality, notification floor, safe-area cap), whose inputs proved unreliable +/// on device: the layout guide froze or lagged transitions and the surface's +/// own frame breathes by the home-indicator height mid-transition, so any +/// self-computed position was wrong at some point of every animation. +/// +/// Self-sizing: `allowsSelfSizing` with internal constraints. The content pins +/// to the safe-area bottom, so the docked state automatically clears the home +/// indicator and the keyboard-attached state sits flush on the keyboard. +final class KeyboardDockAccessoryView: UIInputView { + private let toolbarSlot: UIView + private let composerSlot: UIView + private let toolbarHeight: NSLayoutConstraint + private let composerHeight: NSLayoutConstraint + + /// Creates the dock accessory around the surface-owned toolbar and + /// composer container views. + /// + /// - Parameters: + /// - toolbar: The accessory toolbar row (modifiers, Tab/Esc, toggle). + /// - composer: The composer band container the host mounts SwiftUI into. + /// - toolbarRowHeight: The fixed toolbar strip height. + init(toolbar: UIView, composer: UIView, toolbarRowHeight: CGFloat) { + toolbarSlot = toolbar + composerSlot = composer + toolbarHeight = toolbar.heightAnchor.constraint(equalToConstant: toolbarRowHeight) + composerHeight = composer.heightAnchor.constraint(equalToConstant: 0) + super.init(frame: .zero, inputViewStyle: .keyboard) + allowsSelfSizing = true + translatesAutoresizingMaskIntoConstraints = false + // The dock's Liquid-Glass controls lift past the band edge on drag. + clipsToBounds = false + + toolbar.translatesAutoresizingMaskIntoConstraints = false + composer.translatesAutoresizingMaskIntoConstraints = false + addSubview(toolbar) + addSubview(composer) + NSLayoutConstraint.activate([ + toolbar.topAnchor.constraint(equalTo: topAnchor), + toolbar.leadingAnchor.constraint(equalTo: leadingAnchor), + toolbar.trailingAnchor.constraint(equalTo: trailingAnchor), + toolbarHeight, + composer.topAnchor.constraint(equalTo: toolbar.bottomAnchor), + composer.leadingAnchor.constraint(equalTo: leadingAnchor), + composer.trailingAnchor.constraint(equalTo: trailingAnchor), + composerHeight, + // Safe-area pin: docked = clears the home indicator; on the + // keyboard = flush (the keyboard region has no bottom inset). + composer.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) is not supported") } + + /// The dock's content height above the accessory's safe-area inset. + var contentHeight: CGFloat { + toolbarHeight.constant + composerHeight.constant + } + + /// Resizes the composer band (0 collapses it while the composer is closed). + /// + /// - Parameter height: The band height in points. + /// - Returns: Whether the height changed. + @discardableResult + func setComposerBandHeight(_ height: CGFloat) -> Bool { + let clamped = max(0, height) + guard abs(composerHeight.constant - clamped) > 0.25 else { return false } + composerHeight.constant = clamped + return true + } +} +#endif diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index 35c9aa086ea8..d3d9e49dea38 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -128,6 +128,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { override var canBecomeFirstResponder: Bool { true } + /// Supplies the shared keyboard dock accessory (toolbar row + composer + /// band). Set by `GhosttySurfaceView`; resolved on every keyboard + /// presentation so a chrome toggle can withhold it. + var keyboardAccessoryProvider: (() -> UIView?)? + + override var inputAccessoryView: UIView? { keyboardAccessoryProvider?() } + override func becomeFirstResponder() -> Bool { let wasFirstResponder = isFirstResponder let succeeded = super.becomeFirstResponder() @@ -426,9 +433,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { ) // A short fixed-height strip pinned to the container's BOTTOM (minus - // ``dockedBottomPadding``) that holds the button row. The host pins that - // bottom edge through the composer to `keyboardLayoutGuide.topAnchor`, so - // bottom-pinning the controls keeps them glued to the system keyboard edge. + // ``dockedBottomPadding``) that holds the button row. The row is the top + // slot of the shared keyboard dock accessory, so bottom-pinning the + // controls keeps them glued to the composer band / keyboard edge below. // `dockedBottomPadding` lifts the strip off the very bottom edge so the // controls have breathing room. let buttonRow = UILayoutGuide() @@ -504,11 +511,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// The terminal accessory bar (modifier keys, arrow nub, shortcut buttons). /// - /// Formerly the keyboard `inputAccessoryView`; it is now docked as a - /// persistent bottom bar by ``GhosttySurfaceView`` so it stays visible when - /// the keyboard is dismissed and reserves space above the bottom TUI rows. - /// Its buttons still target this text view, so the action wiring is intact - /// regardless of where the view is hosted. + /// Once again keyboard-accessory-hosted: it is the top row of the shared + /// `KeyboardDockAccessoryView` that ``GhosttySurfaceView`` returns as the + /// `inputAccessoryView` of every cmux keyboard owner, so the system + /// positions it (riding the keyboard while typing, docked at the screen + /// bottom while the surface itself holds first responder). Its buttons + /// still target this text view, so the action wiring is intact regardless + /// of where the view is hosted. var toolbarView: UIView { terminalAccessoryToolbar } private weak var accessoryStackView: UIStackView? @@ -698,12 +707,12 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { backgroundColor = .clear tintColor = .clear // The view owns no visible content; it is a zero-size hidden responder - // docked by `GhosttySurfaceView`. The accessory bar is no longer the - // keyboard's `inputAccessoryView`; `GhosttySurfaceView` docks - // `toolbarView` persistently at the bottom so it survives keyboard - // dismissal. Leaving `inputAccessoryView` nil means the keyboard shows - // without its own accessory (the docked bar rides above it via - // `keyboardLayoutGuide`). + // owned by `GhosttySurfaceView`. The accessory bar is once again + // keyboard-accessory-hosted: `keyboardAccessoryProvider` returns the + // surface's shared `KeyboardDockAccessoryView` (toolbar row + composer + // band), so the system keyboard carries the whole dock on its own + // animation and docks it at the screen bottom when the surface holds + // first responder instead. NotificationCenter.default.addObserver( self, selector: #selector(handleAccessoryConfigurationChanged), diff --git a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift index 6e3b2f12de2b..93c7b95db5fc 100644 --- a/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift +++ b/Packages/iOS/CmuxMobileTerminal/Tests/CmuxMobileTerminalTests/GhosttySurfaceKeyboardDockFloorTests.swift @@ -6,21 +6,23 @@ import UIKit @testable import CmuxMobileTerminal -/// Regression coverage for the field failure where the keyboard rises but the -/// bottom bars (accessory toolbar + composer band) stay seated at the -/// keyboard-down position behind it. +/// Contract coverage for the keyboard dock accessory: the bottom dock (toolbar +/// row + composer band) is one self-sizing `UIInputView` that the OS keyboard +/// system positions. Every cmux keyboard owner returns it as its +/// `inputAccessoryView` — the terminal input proxy while typing (the dock rides +/// the keyboard's own animation) and the surface itself in the keyboard-down +/// state (the system docks it at the screen bottom) — so the surface never +/// computes a dock position. What remains surface-owned is the MODEL: the grid +/// reservation derived from the notification-tracked keyboard overlap. /// -/// In a test window UIKit's `keyboardLayoutGuide` never observes a real -/// keyboard, so it stays on its bottom fallback exactly like a guide that -/// missed a live transition around window (re)attachment. Docking correctly -/// here therefore proves the dock does not depend on the guide having seen the -/// keyboard event. Each test injects a notification-center-isolated -/// `MobileKeyboardFrameTracker` (the floor's single data source) through the -/// surface initializer and calls the view's notification handler directly for -/// animation-curve application, mirroring the production wiring where the +/// A test window never shows a real keyboard, so these tests assert the +/// responder wiring and the model, not OS-owned positions. Each test injects a +/// notification-center-isolated `MobileKeyboardFrameTracker` (the model's +/// single data source) through the surface initializer and calls the view's +/// notification handler directly, mirroring the production wiring where the /// shared tracker observes the same notifications the view does. @MainActor -@Suite("Keyboard dock floor", .serialized) +@Suite("Keyboard dock accessory", .serialized) struct GhosttySurfaceKeyboardDockFloorTests { private final class Delegate: NSObject, GhosttySurfaceViewDelegate { func ghosttySurfaceView( @@ -69,7 +71,9 @@ struct GhosttySurfaceKeyboardDockFloorTests { private func attach(_ view: GhosttySurfaceView, to window: UIWindow) { view.frame = window.bounds window.addSubview(view) - window.isHidden = false + // Key AND visible: first-responder status (the keyboard-down docking + // seat under test) requires the view's window to be key. + window.makeKeyAndVisible() view.setNeedsLayout() view.layoutIfNeeded() } @@ -99,7 +103,7 @@ struct GhosttySurfaceKeyboardDockFloorTests { /// Delivers a keyboard transition the way production sees it: the tracker /// (registered first) records it, then the view's handler re-applies the - /// floor on the notification's animation curve. + /// overlap model on the notification's animation curve. private func deliverKeyboardTransition( coveringBottom overlap: CGFloat, to harness: Harness @@ -122,7 +126,7 @@ struct GhosttySurfaceKeyboardDockFloorTests { } /// The accessory toolbar's keyboard-toggle button, found by its stable - /// accessibility identifier in the surface's subtree. + /// accessibility identifier in the given subtree. private func keyboardToggleButton(in view: UIView) -> UIButton? { if let button = view as? UIButton, button.accessibilityIdentifier == "terminal.inputAccessory.hideKeyboard" { @@ -136,24 +140,71 @@ struct GhosttySurfaceKeyboardDockFloorTests { return nil } - @Test("keyboard rise docks the bars above it even when the layout guide missed it") - func barsRideTheKeyboardWhenTheGuideStaysOnItsFallback() throws { + @Test("the dock accessory exists, hosts toolbar + composer, and is the inputAccessoryView") + func accessoryHostsToolbarAndComposerBand() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + let accessory = try #require( + harness.view.inputAccessoryView as? KeyboardDockAccessoryView + ) + // The toolbar row (identified by its keyboard-toggle button) lives + // INSIDE the accessory, not in the surface's own subtree. + #expect(keyboardToggleButton(in: accessory) != nil) + #expect(keyboardToggleButton(in: harness.view) == nil) + // The composer band is the accessory's other slot: a host-mounted + // compose view lands inside the accessory subtree. + let composerContent = UIView() + harness.view.mountComposerView(composerContent) + #expect(composerContent.isDescendant(of: accessory)) + // The typing responder shares the exact same accessory instance, so + // the dock transfers seamlessly between keyboard owners. + #expect(harness.view.inputProxyForTesting.inputAccessoryView === accessory) + } + + @Test("hidden chrome withholds the accessory from the keyboard system") + func chromeHiddenReturnsNilAccessory() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + #expect(harness.view.inputAccessoryView != nil) + harness.view.setChromeHidden(true) + #expect(harness.view.inputAccessoryView == nil) + #expect(harness.view.inputProxyForTesting.inputAccessoryView == nil) + harness.view.setChromeHidden(false) + #expect(harness.view.inputAccessoryView != nil) + } + + @Test("after attach without autofocus the surface holds first responder (docked accessory state)") + func attachSeatsSurfaceAsFirstResponder() throws { + let harness = try makeHarness() + defer { tearDown(harness) } + + // No cmux text responder owns the keyboard and the chrome is visible, + // so the SURFACE must hold first responder — that is what makes the + // system dock the accessory at the screen bottom. + #expect(harness.view.isFirstResponder) + } + + @Test("a keyboard rise updates the grid overlap model from the tracked window-space overlap") + func keyboardRiseUpdatesOverlapModel() throws { let harness = try makeHarness() defer { tearDown(harness) } deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) - let dockBottom = Self.windowHeight - Self.keyboardHeight - let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) - let toolbarMaxY = try #require(probeValue(of: harness.view, key: "toolbarMaxY")) + // The view fills the window, so the window-space overlap converts 1:1. let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) - #expect(abs(composerMaxY - dockBottom) <= 1) - #expect(abs(toolbarMaxY - dockBottom) <= 1) #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) + + // Dismissal releases the model back to zero. + deliverKeyboardTransition(coveringBottom: 0, to: harness) + let released = try #require(probeValue(of: harness.view, key: "keyboardHeight")) + #expect(abs(released) <= 1) } - @Test("a view attached after the keyboard came up docks from the tracker") - func lateAttachedViewDocksFromTheTrackedKeyboardFrame() throws { + @Test("a view attached after the keyboard came up seats the model from the tracker") + func lateAttachedViewSeatsModelFromTracker() throws { let harness = try makeHarness(attached: false) defer { tearDown(harness) } @@ -163,55 +214,20 @@ struct GhosttySurfaceKeyboardDockFloorTests { harness.center.post(keyboardNotification(coveringBottom: Self.keyboardHeight)) attach(harness.view, to: harness.window) - let dockBottom = Self.windowHeight - Self.keyboardHeight - let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) - let toolbarMaxY = try #require(probeValue(of: harness.view, key: "toolbarMaxY")) let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) - #expect(abs(composerMaxY - dockBottom) <= 1) - #expect(abs(toolbarMaxY - dockBottom) <= 1) #expect(abs(modelKeyboardHeight - Self.keyboardHeight) <= 1) // The visibility bit catches up too: the toggle must read hide-keyboard. #expect(keyboardUp == 1) } - @Test("the floor holds the window-correct seat while the surface's own frame breathes") - func floorStaysWindowCorrectWhenTheSurfaceFrameChanges() throws { - let harness = try makeHarness(attached: false) - defer { tearDown(harness) } - - // Attach with the bottom edge respecting a 34pt indicator inset — the - // shape the host gives the surface while the keyboard is down. - harness.view.frame = CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight - 34) - harness.window.addSubview(harness.view) - harness.window.isHidden = false - harness.view.setNeedsLayout() - harness.view.layoutIfNeeded() - - // The keyboard rises while the frame is still short (mid-transition - // conversion territory: this is where the device build seeded a floor - // 34pt shy of the settled truth). - deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) - - // Safe-area propagation then extends the surface to the window bottom. - harness.view.frame = CGRect(x: 0, y: 0, width: 402, height: Self.windowHeight) - harness.view.setNeedsLayout() - harness.view.layoutIfNeeded() - - // The bars must already sit at the window-correct keyboard top — the - // late +34 pop the dogfood recordings showed is exactly this assertion - // failing at the old view-anchored floor. - let dockBottom = Self.windowHeight - Self.keyboardHeight - let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) - #expect(abs(composerMaxY - dockBottom) <= 1) - } - @Test("a fresh toolbar is born in the keyboard-down state") func freshToolbarShowsTheShowKeyboardToggle() throws { let harness = try makeHarness() defer { tearDown(harness) } - let toggle = try #require(keyboardToggleButton(in: harness.view)) + let accessory = try #require(harness.view.inputAccessoryView) + let toggle = try #require(keyboardToggleButton(in: accessory)) #expect(toggle.accessibilityLabel == "Show Keyboard") let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) #expect(keyboardUp == 0) @@ -231,34 +247,16 @@ struct GhosttySurfaceKeyboardDockFloorTests { harness.view.removeFromSuperview() harness.center.post(keyboardNotification(coveringBottom: 0)) - // Re-enter: the visibility bit, the toggle glyph state, and the dock - // must all reflect the keyboard-down truth. + // Re-enter: the visibility bit and the toggle glyph must reflect the + // keyboard-down truth (positions are OS-owned now, so only the state + // is asserted). attach(harness.view, to: harness.window) let keyboardUp = try #require(probeValue(of: harness.view, key: "keyboardUp")) - let bottomSafeArea = try #require(probeValue(of: harness.view, key: "bottomSafeArea")) - let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) - let toggle = try #require(keyboardToggleButton(in: harness.view)) + let accessory = try #require(harness.view.inputAccessoryView) + let toggle = try #require(keyboardToggleButton(in: accessory)) #expect(keyboardUp == 0) - #expect(abs(composerMaxY - (Self.windowHeight - bottomSafeArea)) <= 1) #expect(toggle.accessibilityLabel == "Show Keyboard") } - - @Test("a dismissal transition releases the floor and reseats the bars at the bottom") - func dismissalReturnsTheBarsToTheBottomFallback() throws { - let harness = try makeHarness() - defer { tearDown(harness) } - - deliverKeyboardTransition(coveringBottom: Self.keyboardHeight, to: harness) - deliverKeyboardTransition(coveringBottom: 0, to: harness) - - // Released, the dock reseats on the guide's bottom fallback, which - // clears the simulator device's real bottom safe-area inset. - let bottomSafeArea = try #require(probeValue(of: harness.view, key: "bottomSafeArea")) - let composerMaxY = try #require(probeValue(of: harness.view, key: "composerMaxY")) - let modelKeyboardHeight = try #require(probeValue(of: harness.view, key: "keyboardHeight")) - #expect(abs(composerMaxY - (Self.windowHeight - bottomSafeArea)) <= 1) - #expect(abs(modelKeyboardHeight) <= 1) - } } #endif From 3fab1b11759348e402ff26e8a6124348572cb2e6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:57:52 -0700 Subject: [PATCH 14/14] iOS: keyboard toggle decides from responder truth, not the tracked bit Rapid successive toggles (Aziz's overnight repro) desynced: keyboardVisible is reconciled from keyboard notifications and lags during back-to-back transitions, so a quick re-tap re-focused when it should resign until the keyboard wedged against the button. Branch on the actual first responder instead; the tracked bit remains display-only (glyph). Co-Authored-By: Claude Fable 5 --- .../Sources/CmuxMobileTerminal/GhosttySurfaceView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 62164ec5aac2..bd886bf6e692 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -709,7 +709,12 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // terminal tap focuses the proxy without closing the band), and the proxy // is a sibling of `composerContainer`, so `endEditing` on the container // alone would resign nothing and the keyboard would stay up. - if self.keyboardVisible { + // Decide from ACTUAL responder truth, not `keyboardVisible`: that + // bit is reconciled from keyboard notifications and lags a frame + // or two during rapid toggling, which made quick successive taps + // of the toggle re-focus when they should resign (and vice versa) + // until the keyboard wedged out of sync with the button. + if self.inputProxy.isFirstResponder || self.composerFieldIsFirstResponder { self.resignCurrentInput() } else { self.focusInput()