From e22e1f72afcde88fc52c24117dbccdeac56f6dad Mon Sep 17 00:00:00 2001 From: dand Date: Sat, 3 Oct 2026 23:48:22 -0400 Subject: [PATCH 1/2] fix(mobile): allow copying iOS terminal output --- .../t3-terminal/ios/T3TerminalView.swift | 48 ++++++++++++--- .../TerminalTextSelectionViewController.swift | 59 +++++++++++++++++++ 2 files changed, 98 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 9e382bfcd7eda..a32ac83938c94 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -201,6 +201,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private let inputField = TerminalInputField() private let focusTapGesture = UITapGestureRecognizer() private let scrollPanGesture = UIPanGestureRecognizer() + private let selectTextGesture = UILongPressGestureRecognizer() private var lastViewportSize: CGSize = .zero private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? @@ -219,15 +220,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { var captureRequest: Double = 0 { didSet { guard captureRequest > 0, captureRequest != oldValue else { return } - guard let surface else { onCapture(["text": ""]); return } - let selection = ghostty_selection_s( - top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), - bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), - rectangle: false) - var captured = ghostty_text_s() - guard ghostty_surface_read_text(surface, selection, &captured) else { onCapture(["text": ""]); return } - defer { ghostty_surface_free_text(surface, &captured) } - let text = captured.text.flatMap { String(bytes: UnsafeBufferPointer(start: UnsafeRawPointer($0).assumingMemoryBound(to: UInt8.self), count: Int(captured.text_len)), encoding: .utf8) } ?? "" + let text = readViewportText() ?? "" // Android joins snapshot rows with "\n" and trims each row's trailing whitespace, so do // the same here: identical terminal content must capture identically on both platforms. let normalized = text.split(separator: "\n", omittingEmptySubsequences: false) @@ -355,6 +348,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { scrollPanGesture.maximumNumberOfTouches = 1 scrollPanGesture.cancelsTouchesInView = false terminalViewport.addGestureRecognizer(scrollPanGesture) + selectTextGesture.addTarget(self, action: #selector(handleSelectText(_:))) + terminalViewport.addGestureRecognizer(selectTextGesture) + focusTapGesture.require(toFail: selectTextGesture) addSubview(terminalViewport) addSubview(inputField) @@ -426,6 +422,40 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { requestKeyboardFocus() } + @objc + private func handleSelectText(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began, + let presenter = appContext?.utilities?.currentViewController(), + let text = readViewportText(), + !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + else { return } + + // Capture before dismissing the keyboard: doing so can resize the remote PTY. + // UIKit owns selection in this snapshot, so live output cannot move its endpoints. + inputField.resignFirstResponder() + let controller = TerminalTextSelectionViewController(text: text, fontSize: fontSize) + let navigation = UINavigationController(rootViewController: controller) + navigation.modalPresentationStyle = .pageSheet + presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) + } + + private func readViewportText() -> String? { + guard let surface else { return nil } + let selection = ghostty_selection_s( + top_left: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_TOP_LEFT, x: 0, y: 0), + bottom_right: ghostty_point_s(tag: GHOSTTY_POINT_VIEWPORT, coord: GHOSTTY_POINT_COORD_BOTTOM_RIGHT, x: 0, y: 0), + rectangle: false) + var captured = ghostty_text_s() + guard ghostty_surface_read_text(surface, selection, &captured) else { return nil } + defer { ghostty_surface_free_text(surface, &captured) } + guard let text = captured.text else { return nil } + return String( + bytes: UnsafeBufferPointer( + start: UnsafeRawPointer(text).assumingMemoryBound(to: UInt8.self), + count: Int(captured.text_len)), + encoding: .utf8) + } + @objc private func handleViewportPan(_ gesture: UIPanGestureRecognizer) { guard let surface else { return } diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift new file mode 100644 index 0000000000000..b4c1ec23fa820 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift @@ -0,0 +1,59 @@ +import UIKit + +/// A frozen viewport lets UIKit provide selection handles without competing with +/// terminal scrolling or changing the remote shell's input. +final class TerminalTextSelectionViewController: UIViewController { + private let text: String + private let fontSize: CGFloat + + init(text: String, fontSize: CGFloat) { + self.text = text + self.fontSize = fontSize + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + return nil + } + + override func viewDidLoad() { + super.viewDidLoad() + title = "Terminal text" + view.backgroundColor = .systemBackground + + navigationItem.leftBarButtonItem = UIBarButtonItem( + title: "Copy All", style: .plain, target: self, action: #selector(copyAll)) + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, target: self, action: #selector(close)) + + let textView = UITextView() + textView.text = text + textView.font = .monospacedSystemFont(ofSize: max(fontSize, 14), weight: .regular) + textView.textColor = .label + textView.backgroundColor = .systemBackground + textView.isEditable = false + textView.isSelectable = true + textView.accessibilityLabel = "Terminal output" + textView.textContainerInset = UIEdgeInsets(top: 16, left: 12, bottom: 16, right: 12) + textView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(textView) + + NSLayoutConstraint.activate([ + textView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + textView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + ]) + } + + @objc + private func copyAll() { + UIPasteboard.general.string = text + close() + } + + @objc + private func close() { + dismiss(animated: !UIAccessibility.isReduceMotionEnabled) + } +} From f7b33b871fcfac7f1bcc78c733cd49fa487bc6a1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 02:52:42 +0000 Subject: [PATCH 2/2] fix(mobile): present terminal selection from its own window --- .../modules/t3-terminal/ios/T3TerminalView.swift | 15 ++++++++++++--- .../ios/TerminalTextSelectionViewController.swift | 2 -- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 52565e0ba5c18..51d6c4ce62c90 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -432,14 +432,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { @objc private func handleSelectText(_ gesture: UILongPressGestureRecognizer) { - guard gesture.state == .began, - let presenter = appContext?.utilities?.currentViewController(), + guard gesture.state == .began, let window else { return } + + var responder: UIResponder? = self + while let current = responder, !(current is UIViewController) { + responder = current.next + } + guard let presenter = responder as? UIViewController, + presenter.viewIfLoaded?.window === window, + presenter.presentedViewController == nil, + !presenter.isBeingPresented, + !presenter.isBeingDismissed, + presenter.transitionCoordinator == nil, let text = readViewportText(), !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } // Capture before dismissing the keyboard: doing so can resize the remote PTY. - // UIKit owns selection in this snapshot, so live output cannot move its endpoints. inputField.resignFirstResponder() let controller = TerminalTextSelectionViewController(text: text, fontSize: fontSize) let navigation = UINavigationController(rootViewController: controller) diff --git a/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift index b4c1ec23fa820..9f3fe8422ad1d 100644 --- a/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift +++ b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift @@ -1,7 +1,5 @@ import UIKit -/// A frozen viewport lets UIKit provide selection handles without competing with -/// terminal scrolling or changing the remote shell's input. final class TerminalTextSelectionViewController: UIViewController { private let text: String private let fontSize: CGFloat