From fe89593d6d8be350ac34cd58a4eb536a576cc68b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:51:03 -0700 Subject: [PATCH 1/6] fix(mobile): iOS terminal can select, copy and paste Long-pressing the iOS terminal opens an edit menu with Paste and Select Text. Select Text shows the visible output in a native text view where users can select, copy, or tap a link. Paste, Cmd+V and the iPad shortcut bar now go through the toolbar's paste path, so multi-line text keeps its newlines. The toolbar's Paste sits next to Dismiss keyboard instead of off-screen. The terminal output sheet scrolls to its last line and keeps its attach button on screen, and Attach visible output clears the home indicator. Co-authored-by: danddanddand <47586871+danddanddand@users.noreply.github.com> Co-authored-by: whoisaldo Co-authored-by: maria-rcks Co-authored-by: juliusmarminge Co-authored-by: Claudesaul <139932571+Claudesaul@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 --- .../t3-terminal/ios/T3TerminalModule.swift | 4 +- .../t3-terminal/ios/T3TerminalView.swift | 128 ++++++++++++++++-- .../TerminalTextSelectionViewController.swift | 59 ++++++++ apps/mobile/src/components/ui/AppSymbol.tsx | 2 + apps/mobile/src/components/ui/Sheet.tsx | 3 +- .../terminal/NativeTerminalSurface.test.tsx | 54 +++++++- .../terminal/NativeTerminalSurface.tsx | 4 + .../terminal/ThreadTerminalRouteScreen.tsx | 34 +++-- .../features/terminal/nativeTerminalModule.ts | 6 + 9 files changed, 269 insertions(+), 25 deletions(-) create mode 100644 apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift index 2f7445265886c..b11b55ce6026d 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift @@ -7,7 +7,7 @@ public class T3TerminalModule: Module { // Bumped when native hardware-keyboard handling changes; surfaced in the JS debug // logs so a stale native binary is distinguishable from a broken key pipeline. Constants([ - "hardwareKeyRevision": 3, + "hardwareKeyRevision": 4, "outputRevision": 1, ]) @@ -63,7 +63,7 @@ public class T3TerminalModule: Module { Prop("captureRequest") { (view: T3TerminalView, request: Double) in view.captureRequest = request } - Events("onInput", "onResize", "onCapture") + Events("onInput", "onResize", "onCapture", "onPaste") } } } diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 364880aaeb39b..b2ef44e0335b8 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -128,6 +128,7 @@ private enum TerminalInputSequence { private final class TerminalInputField: UITextField { var onDeleteBackward: (() -> Void)? var onInsert: ((String) -> Void)? + var onPaste: (() -> Void)? private static let hardwareKeyCommands = TerminalHardwareKeyEncoder.makeKeyCommands( action: #selector(handleHardwareKeyCommand(_:)) @@ -142,6 +143,11 @@ private final class TerminalInputField: UITextField { super.deleteBackward() } + // System pastes must bypass typed input to preserve paste encoding and chunking. + override func paste(_ sender: Any?) { + onPaste?() + } + @objc private func handleHardwareKeyCommand(_ command: UIKeyCommand) { guard let input = command.input else { return } @@ -184,7 +190,7 @@ private extension UIColor { } } -public final class T3TerminalView: ExpoView, UITextFieldDelegate { +public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInteractionDelegate { private static let minimumVerticalScrollStepPoints: CGFloat = 18 private static let verticalScrollStepMultiplier: CGFloat = 1.15 @@ -192,12 +198,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private let inputField = TerminalInputField() private let focusTapGesture = UITapGestureRecognizer() private let scrollPanGesture = UIPanGestureRecognizer() + private let editMenuGesture = UILongPressGestureRecognizer() + private lazy var editMenuInteraction = UIEditMenuInteraction(delegate: self) private var lastViewportSize: CGSize = .zero private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? private var lastAppliedBuffer = "" private var streamedOutput: TerminalOutputBuffer? private var pendingVerticalScrollPoints: CGFloat = 0 + private var menuText: String? private var app: ghostty_app_t? private var surface: ghostty_surface_t? private var isCreatingSurface = false @@ -208,18 +217,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { let onInput = EventDispatcher() let onResize = EventDispatcher() let onCapture = EventDispatcher() + let onPaste = EventDispatcher() 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) @@ -348,6 +350,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { inputField.onInsert = { [weak self] data in self?.emitInput(data) } + inputField.onPaste = { [weak self] in + self?.emitPaste() + } focusTapGesture.addTarget(self, action: #selector(handleViewportTap)) terminalViewport.addGestureRecognizer(focusTapGesture) @@ -355,6 +360,10 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { scrollPanGesture.maximumNumberOfTouches = 1 scrollPanGesture.cancelsTouchesInView = false terminalViewport.addGestureRecognizer(scrollPanGesture) + editMenuGesture.addTarget(self, action: #selector(handleViewportLongPress(_:))) + terminalViewport.addGestureRecognizer(editMenuGesture) + terminalViewport.addInteraction(editMenuInteraction) + focusTapGesture.require(toFail: editMenuGesture) addSubview(terminalViewport) addSubview(inputField) @@ -426,6 +435,23 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { requestKeyboardFocus() } + 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 } @@ -466,6 +492,83 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + @objc + private func handleViewportLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began else { return } + // Freeze the text the user pressed on; output keeps streaming while the menu is open. + menuText = readViewportText().flatMap { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : $0 } + guard menuText != nil || canPaste else { return } + editMenuInteraction.presentEditMenu( + with: UIEditMenuConfiguration(identifier: nil, sourcePoint: gesture.location(in: terminalViewport)) + ) + } + + public func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + // The hidden input field would otherwise add actions such as Scan Text or Writing Tools. + // Keep the system Paste command: unlike a custom action, it reads without a paste prompt. + var children: [UIMenuElement] = Self.pasteCommand(in: suggestedActions).map { [$0] } ?? [] + if let text = menuText { + children.append( + UIAction(title: "Select Text", image: UIImage(systemName: "text.cursor")) { [weak self] _ in + self?.presentTextSelection(text) + }) + } + return UIMenu(children: children) + } + + private func presentTextSelection(_ text: String) { + guard 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 + else { return } + + inputField.resignFirstResponder() + let controller = TerminalTextSelectionViewController(text: text, fontSize: fontSize) + let navigation = UINavigationController(rootViewController: controller) + navigation.modalPresentationStyle = .pageSheet + presenter.present(navigation, animated: !UIAccessibility.isReduceMotionEnabled) + } + + private static func pasteCommand(in elements: [UIMenuElement]) -> UIMenuElement? { + for element in elements { + if let command = element as? UICommand, + command.action == #selector(UIResponderStandardEditActions.paste(_:)) { + return command + } + if let menu = element as? UIMenu, let command = pasteCommand(in: menu.children) { + return command + } + } + return nil + } + + public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(UIResponderStandardEditActions.paste(_:)) { + return canPaste + } + return super.canPerformAction(action, withSender: sender) + } + + public override func paste(_ sender: Any?) { + emitPaste() + } + + private var canPaste: Bool { + !readOnly && UIPasteboard.general.hasStrings + } + @objc private func handleInputEditingDidBegin() { textInputModeDidChange() @@ -716,6 +819,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { onInput(["data": data]) } + private func emitPaste() { + guard !readOnly, let text = UIPasteboard.general.string, !text.isEmpty else { return } + onPaste(["text": text]) + } + private func textInputModeDidChange() { guard let app else { return } ghostty_app_keyboard_changed(app) 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..5eb2960d93068 --- /dev/null +++ b/apps/mobile/modules/t3-terminal/ios/TerminalTextSelectionViewController.swift @@ -0,0 +1,59 @@ +import UIKit + +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 + // Login flows print auth URLs; tapping one opens it. + textView.dataDetectorTypes = .link + 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) + } +} diff --git a/apps/mobile/src/components/ui/AppSymbol.tsx b/apps/mobile/src/components/ui/AppSymbol.tsx index 4b17bddd3c324..7dfe2ee43ad96 100644 --- a/apps/mobile/src/components/ui/AppSymbol.tsx +++ b/apps/mobile/src/components/ui/AppSymbol.tsx @@ -45,6 +45,7 @@ import IconMoodPlus from "@tabler/icons-react-native/IconMoodPlus"; import IconTag from "@tabler/icons-react-native/IconTag"; import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; +import IconClipboard from "@tabler/icons-react-native/IconClipboard"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; import IconDatabase from "@tabler/icons-react-native/IconDatabase"; @@ -306,6 +307,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { close: IconX, construction: IconHammer, content_copy: IconCopy, + content_paste: IconClipboard, desktop_windows: IconDeviceDesktop, edit: IconEdit, error: IconAlertCircle, diff --git a/apps/mobile/src/components/ui/Sheet.tsx b/apps/mobile/src/components/ui/Sheet.tsx index 56994803b4ab8..679e4d4c30724 100644 --- a/apps/mobile/src/components/ui/Sheet.tsx +++ b/apps/mobile/src/components/ui/Sheet.tsx @@ -30,7 +30,8 @@ export function Sheet(props: SheetProps) { onDismiss={props.onClose} onNativeDismiss={props.onClose} > - + {/* Flex children are not bounded by the FormSheet host, so pin to its edges. */} + ({ revision: null as number | null, @@ -16,9 +18,17 @@ const mocks = vi.hoisted(() => ({ mounts: vi.fn(), unmounts: vi.fn(), write: vi.fn(async (_data: string, _reset: boolean) => {}), + paste: (_text: string) => {}, })); -function RawView({ ref: nativeRef, initialBuffer, onResize }: NativeTerminalSurfaceProps) { +function RawView({ ref: nativeRef, initialBuffer, onResize, onPaste }: NativeTerminalSurfaceProps) { + useLayoutEffect(() => { + mocks.paste = (text) => + onPaste?.({ nativeEvent: { text } } as NativeSyntheticEvent<{ text: string }>); + return () => { + mocks.paste = () => {}; + }; + }, [onPaste]); useImperativeHandle(nativeRef, () => ({ writeOutput: mocks.write })); useLayoutEffect(() => { mocks.mounts(); @@ -81,6 +91,11 @@ function setup(revision: number | null) { async render( state: typeof EMPTY_TERMINAL_BUFFER_STATE, terminalKey = "environment:thread:term-1", + input: { + isRunning?: boolean; + readOnly?: boolean; + onPaste?: (text: string) => void; + } = {}, ) { await act(async () => { root.render( @@ -97,6 +112,7 @@ function setup(revision: number | null) { isRunning onInput={() => {}} onResize={() => {}} + {...input} />, ); }); @@ -151,3 +167,39 @@ it.each([null, 1])( } }, ); + +it.each([ + { isRunning: true, readOnly: false }, + { isRunning: true, readOnly: true }, + { isRunning: false, readOnly: false }, +])("native paste preserves text and write limits for %j", async ({ isRunning, readOnly }) => { + await using harness = setup(1); + const session = createTerminalPasteSession(); + session.reset(true); + const writes: string[] = []; + let paste: Promise | undefined; + await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { + isRunning, + readOnly, + onPaste: (text) => { + paste = session.paste({ + readText: async () => text, + write: async (data) => { + writes.push(data); + return true; + }, + onReadError: (cause) => { + throw cause; + }, + }); + }, + }); + mocks.paste(`one\ntwo\u0000${"x".repeat(TERMINAL_WRITE_MAX_LENGTH)}`); + await paste; + expect(writes.join("")).toBe( + isRunning && !readOnly ? `one\rtwo ${"x".repeat(TERMINAL_WRITE_MAX_LENGTH)}` : "", + ); + expect(writes.map((chunk) => chunk.length)).toEqual( + isRunning && !readOnly ? [TERMINAL_WRITE_MAX_LENGTH, 8] : [], + ); +}); diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index ff3e0c1dae43e..16d20140d050a 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -65,6 +65,7 @@ interface TerminalSurfaceProps extends ViewProps { readonly onCapture?: (text: string) => void; readonly theme?: TerminalTheme; readonly onInput: (data: string) => void; + readonly onPaste?: (text: string) => void; readonly onResize: (size: { readonly cols: number; readonly rows: number }) => void; } @@ -290,6 +291,9 @@ const ReadyTerminalSurface = memo(function ReadyTerminalSurface(props: ReadyTerm style={{ flex: 1 }} themeConfig={buildGhosttyThemeConfig(theme)} onInput={handleNativeInput} + onPaste={(event) => { + if (props.isRunning && !props.readOnly) props.onPaste?.(event.nativeEvent.text); + }} onResize={handleNativeResize} captureRequest={props.captureRequest} onCapture={(event) => props.onCapture?.(event.nativeEvent.text)} diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index de0c090f93994..b0b09cf24437c 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -194,7 +194,6 @@ class TerminalClipboardReadError extends Schema.TaggedError { + const pasteFromClipboard = async (readText: () => Promise = Clipboard.getStringAsync) => { await pasteSession.paste({ - readText: Clipboard.getStringAsync, + readText, write: writeInput, onReadError: (cause) => { console.error(new TerminalClipboardReadError({ terminalId, cause })); @@ -695,6 +693,11 @@ function ThreadTerminal(props: { writeModifiedInput(data); }; + const handleNativePaste = (text: string) => { + setPendingModifier(null); + void pasteFromClipboard(async () => text); + }; + const handleResize = (size: { readonly cols: number; readonly rows: number }) => { terminalDebugLog("native:onResize", { cols: size.cols, @@ -796,15 +799,14 @@ function ThreadTerminal(props: { return; } - if (action.kind === "paste") { - setPendingModifier(null); - void pasteFromClipboard(); - return; - } - writeModifiedInput(action.data); }; + const handlePaste = () => { + setPendingModifier(null); + void pasteFromClipboard(); + }; + const handleDismissKeyboard = () => { setAccessory((current) => ({ ...current, dismissed: true })); void KeyboardController.dismiss(); @@ -971,6 +973,7 @@ function ThreadTerminal(props: { Dialog.alert("No terminal output", "There is no visible output to attach."); }} onInput={handleInput} + onPaste={handleNativePaste} onResize={handleResize} style={{ flex: 1 }} terminalKey={terminalKey} @@ -998,7 +1001,10 @@ function ThreadTerminal(props: { KeyboardController.dismiss(); requestCapture(); }} - className="px-4 py-2" + className="px-4 pt-2" + style={{ + paddingBottom: 8 + (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom), + }} > Attach visible output @@ -1044,6 +1050,12 @@ function ThreadTerminal(props: { ); })} + ) => void; + /** iOS only: clipboard text from a system paste (edit menu, ⌘V, shortcut bar). */ + readonly onPaste?: (event: NativeSyntheticEvent) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; } From 70b7d3c2c3370cc284f14575403c381c0fa28d57 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:57:33 -0700 Subject: [PATCH 2/6] fix(mobile): address council round 1 review Co-Authored-By: Claude Opus 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 2 +- .../terminal/NativeTerminalSurface.test.tsx | 53 ++++++------------- .../terminal/ThreadTerminalRouteScreen.tsx | 6 ++- docs/user/terminal.md | 3 ++ 4 files changed, 26 insertions(+), 38 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index b2ef44e0335b8..d487e84b8a18a 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -517,7 +517,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte self?.presentTextSelection(text) }) } - return UIMenu(children: children) + return children.isEmpty ? nil : UIMenu(children: children) } private func presentTextSelection(_ text: String) { diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx index 8e35b0a6f894d..99c8620450fb0 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx @@ -9,8 +9,6 @@ import type { NativeSyntheticEvent } from "react-native"; import { expect, it, vi } from "vite-plus/test"; import type { NativeTerminalSurfaceProps } from "./nativeTerminalModule"; -import { TERMINAL_WRITE_MAX_LENGTH } from "./terminalInput"; -import { createTerminalPasteSession } from "./terminalPaste"; const mocks = vi.hoisted(() => ({ revision: null as number | null, @@ -169,37 +167,20 @@ it.each([null, 1])( ); it.each([ - { isRunning: true, readOnly: false }, - { isRunning: true, readOnly: true }, - { isRunning: false, readOnly: false }, -])("native paste preserves text and write limits for %j", async ({ isRunning, readOnly }) => { - await using harness = setup(1); - const session = createTerminalPasteSession(); - session.reset(true); - const writes: string[] = []; - let paste: Promise | undefined; - await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { - isRunning, - readOnly, - onPaste: (text) => { - paste = session.paste({ - readText: async () => text, - write: async (data) => { - writes.push(data); - return true; - }, - onReadError: (cause) => { - throw cause; - }, - }); - }, - }); - mocks.paste(`one\ntwo\u0000${"x".repeat(TERMINAL_WRITE_MAX_LENGTH)}`); - await paste; - expect(writes.join("")).toBe( - isRunning && !readOnly ? `one\rtwo ${"x".repeat(TERMINAL_WRITE_MAX_LENGTH)}` : "", - ); - expect(writes.map((chunk) => chunk.length)).toEqual( - isRunning && !readOnly ? [TERMINAL_WRITE_MAX_LENGTH, 8] : [], - ); -}); + { isRunning: true, readOnly: false, delivered: ["one\ntwo"] }, + { isRunning: true, readOnly: true, delivered: [] }, + { isRunning: false, readOnly: false, delivered: [] }, +])( + "native paste delivers multi-line clipboard text only to a writable running terminal: %j", + async ({ isRunning, readOnly, delivered }) => { + await using harness = setup(1); + const pasted: string[] = []; + await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { + isRunning, + readOnly, + onPaste: (text) => pasted.push(text), + }); + mocks.paste("one\ntwo"); + expect(pasted).toEqual(delivered); + }, +); diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index b0b09cf24437c..3982d2ac148f3 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -1003,7 +1003,11 @@ function ThreadTerminal(props: { }} className="px-4 pt-2" style={{ - paddingBottom: 8 + (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom), + paddingBottom: + 8 + + (keyboardState.isVisible + ? 0 + : (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom)), }} > Attach visible output diff --git a/docs/user/terminal.md b/docs/user/terminal.md index 347b051f0bc89..dd921eb9a9650 100644 --- a/docs/user/terminal.md +++ b/docs/user/terminal.md @@ -17,6 +17,9 @@ then copy. Cmd+A on macOS and Ctrl+Shift+A elsewhere select the same output. Ctrl+A still moves to the beginning of shell input on Windows and Linux. Jump to latest in the context menu returns to the current output. +On iPhone and iPad, long-press the terminal to paste or to choose Select Text. +Select Text shows the visible output, where you can copy text or tap a link. + ## fish T3 Code starts fish with `no-query-term`, avoiding startup waits for terminal From e76f1ca5c73e01826a1985f051f78fdb5c5cf21f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 00:25:46 -0700 Subject: [PATCH 3/6] fix(mobile): iOS terminal drains Ghostty's app mailbox The io thread blocked on a full app mailbox, so feeding output or freeing the surface hung the main thread. Also hide Attach visible output while the keyboard is up. Co-Authored-By: Claude Opus 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 28 +++++++++++++++++-- .../terminal/NativeTerminalSurface.test.tsx | 1 + .../terminal/ThreadTerminalRouteScreen.tsx | 10 +++---- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d487e84b8a18a..2eaada9df4bef 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -110,6 +110,13 @@ private enum TerminalHardwareKeyEncoder { } } +/// Ghostty's io thread queues app messages (title changes, bells) and calls `wakeup_cb`. +/// Unless the main thread drains them with `ghostty_app_tick`, the bounded queue fills, the +/// io thread blocks, and the next feed or surface free on the main thread hangs the app. +private final class GhosttyAppWakeup { + weak var view: T3TerminalView? +} + private enum TerminalInputSequence { /// Terminal Enter is carriage return. Sending line feed instead is Ctrl+J, /// which raw-mode TUIs may interpret as the literal J key. @@ -208,6 +215,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte private var pendingVerticalScrollPoints: CGFloat = 0 private var menuText: String? private var app: ghostty_app_t? + private var appWakeup: GhosttyAppWakeup? private var surface: ghostty_surface_t? private var isCreatingSurface = false private var surfaceCreationFailed = false @@ -585,10 +593,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte isCreatingSurface = true defer { isCreatingSurface = false } + let wakeup = GhosttyAppWakeup() + wakeup.view = self var runtimeConfig = ghostty_runtime_config_s( - userdata: Unmanaged.passUnretained(self).toOpaque(), + userdata: Unmanaged.passUnretained(wakeup).toOpaque(), supports_selection_clipboard: false, - wakeup_cb: { _ in }, + wakeup_cb: { userdata in + guard let userdata else { return } + let wakeup = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + DispatchQueue.main.async { wakeup.view?.tickApp() } + }, action_cb: { _, _, _ in false }, read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, confirm_read_clipboard_cb: { _, _, _, _ in }, @@ -625,6 +639,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } app = createdApp + appWakeup = wakeup surface = createdSurface ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme) ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) @@ -653,6 +668,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } private func destroySurface() { + // Unblock the io thread before ghostty_surface_free joins it. + tickApp() if let surface { ghostty_surface_set_write_callback(surface, nil, nil) ghostty_surface_free(surface) @@ -662,6 +679,12 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } surface = nil app = nil + appWakeup = nil + } + + private func tickApp() { + guard let app else { return } + ghostty_app_tick(app) } private func applyRemoteBuffer(_ buffer: String) { @@ -711,6 +734,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte private func feedData(_ data: Data) { guard let surface, !data.isEmpty else { return } + tickApp() data.withUnsafeBytes { buffer in guard let pointer = buffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { return diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx index 99c8620450fb0..87fd236d775e6 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx @@ -71,6 +71,7 @@ function setup(revision: number | null) { mocks.write.mockResolvedValue(undefined); mocks.mounts.mockClear(); mocks.unmounts.mockClear(); + mocks.paste = () => {}; const document = { nodeType: 9, addEventListener() {}, removeEventListener() {} }; const container = { nodeType: 1, diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 3982d2ac148f3..733131228d5d1 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -994,7 +994,9 @@ function ThreadTerminal(props: { onPress={handleShowKeyboard} /> - ) : Platform.OS !== "android" && hasNativeTerminalSurface() ? ( + ) : Platform.OS !== "android" && + !keyboardState.isVisible && + hasNativeTerminalSurface() ? ( { @@ -1003,11 +1005,7 @@ function ThreadTerminal(props: { }} className="px-4 pt-2" style={{ - paddingBottom: - 8 + - (keyboardState.isVisible - ? 0 - : (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom)), + paddingBottom: 8 + (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom), }} > Attach visible output From 4932d07ffe645e188b792bd5487d4bec0e3627b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 01:02:31 -0700 Subject: [PATCH 4/6] fix(mobile): iOS terminal creates one surface per layout pass A theme switch freed a surface while it replayed history. Ghostty callbacks now reach the view through a weak handle. Co-Authored-By: Claude Opus 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 2eaada9df4bef..067ad829b6dac 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -110,10 +110,8 @@ private enum TerminalHardwareKeyEncoder { } } -/// Ghostty's io thread queues app messages (title changes, bells) and calls `wakeup_cb`. -/// Unless the main thread drains them with `ghostty_app_tick`, the bounded queue fills, the -/// io thread blocks, and the next feed or surface free on the main thread hangs the app. -private final class GhosttyAppWakeup { +/// Weak handle for Ghostty callbacks, which can run on the io thread after the view is gone. +private final class TerminalViewRef { weak var view: T3TerminalView? } @@ -215,7 +213,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte private var pendingVerticalScrollPoints: CGFloat = 0 private var menuText: String? private var app: ghostty_app_t? - private var appWakeup: GhosttyAppWakeup? + private let callbackRef = TerminalViewRef() private var surface: ghostty_surface_t? private var isCreatingSurface = false private var surfaceCreationFailed = false @@ -263,7 +261,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - refreshSurface() + resetSurface() } } @@ -299,14 +297,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - refreshSurface() + resetSurface() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - refreshSurface() + resetSurface() } } @@ -322,6 +320,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) + callbackRef.view = self applyTheme() clipsToBounds = true @@ -593,15 +592,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte isCreatingSurface = true defer { isCreatingSurface = false } - let wakeup = GhosttyAppWakeup() - wakeup.view = self var runtimeConfig = ghostty_runtime_config_s( - userdata: Unmanaged.passUnretained(wakeup).toOpaque(), + userdata: Unmanaged.passUnretained(callbackRef).toOpaque(), supports_selection_clipboard: false, + // The io thread queues app messages (titles, bells) in a bounded mailbox. Unless the main + // thread drains it, the io thread blocks and the next feed or surface free hangs the app. wakeup_cb: { userdata in guard let userdata else { return } - let wakeup = Unmanaged.fromOpaque(userdata).takeUnretainedValue() - DispatchQueue.main.async { wakeup.view?.tickApp() } + let ref = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + DispatchQueue.main.async { ref.view?.tickApp() } }, action_cb: { _, _, _ in false }, read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, @@ -639,7 +638,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } app = createdApp - appWakeup = wakeup surface = createdSurface ghostty_app_set_color_scheme(createdApp, appearance.ghosttyColorScheme) ghostty_surface_set_color_scheme(createdSurface, appearance.ghosttyColorScheme) @@ -662,11 +660,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } - private func refreshSurface() { - resetSurface() - createSurfaceIfPossible() - } - private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -679,7 +672,6 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } surface = nil app = nil - appWakeup = nil } private func tickApp() { @@ -717,7 +709,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte output.write(bytes, reset: reset) streamedOutput = output if reset { - refreshSurface() + resetSurface() } else if surface == nil { createSurfaceIfPossible() } else { @@ -748,15 +740,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte private func setupWriteCallback() { guard let surface else { return } - let userdata = Unmanaged.passUnretained(self).toOpaque() + let userdata = Unmanaged.passUnretained(callbackRef).toOpaque() ghostty_surface_set_write_callback(surface, { userdata, data, len in guard let userdata, let data, len > 0 else { return } - let view = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + let ref = Unmanaged.fromOpaque(userdata).takeUnretainedValue() let bytes = Data(bytes: data, count: len) guard let input = String(data: bytes, encoding: .utf8), !input.isEmpty else { return } DispatchQueue.main.async { - view.emitInput(input) + ref.view?.emitInput(input) } }, userdata) } From 4984d8027cc88489fee6a598c4cda70bd79e9457 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 21:40:31 +0000 Subject: [PATCH 5/6] test(mobile): takeover tests for iOS terminal native paste Replace the PR's it.each over isRunning/readOnly with one test that pastes across the writable, read-only and exited states of the same surface. Co-Authored-By: Claude Opus 5.5 --- .../terminal/NativeTerminalSurface.test.tsx | 30 ++++++++----------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx index 87fd236d775e6..5202257e3bc8e 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx @@ -167,21 +167,15 @@ it.each([null, 1])( }, ); -it.each([ - { isRunning: true, readOnly: false, delivered: ["one\ntwo"] }, - { isRunning: true, readOnly: true, delivered: [] }, - { isRunning: false, readOnly: false, delivered: [] }, -])( - "native paste delivers multi-line clipboard text only to a writable running terminal: %j", - async ({ isRunning, readOnly, delivered }) => { - await using harness = setup(1); - const pasted: string[] = []; - await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { - isRunning, - readOnly, - onPaste: (text) => pasted.push(text), - }); - mocks.paste("one\ntwo"); - expect(pasted).toEqual(delivered); - }, -); +it("forwards a native paste only while the terminal is writable and running", async () => { + await using harness = setup(1); + const pasted: string[] = []; + const onPaste = (text: string) => pasted.push(text); + await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { onPaste }); + mocks.paste("one\ntwo"); + await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { onPaste, readOnly: true }); + mocks.paste("read-only"); + await harness.render(EMPTY_TERMINAL_BUFFER_STATE, undefined, { onPaste, isRunning: false }); + mocks.paste("exited"); + expect(pasted).toEqual(["one\ntwo"]); +}); From 256fbd44eb06b16dc0d6cd3dbe1e58d3feed08e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 21:45:24 +0000 Subject: [PATCH 6/6] fix(mobile): iOS terminal routes every paste through one handler The toolbar button and the native paste event now share one handlePaste that takes the text source, and paste gating lives in JS (writable and running) with native only hiding the menu item. The callback handle's comment now states the lifetime it actually guards. Co-Authored-By: Claude Opus 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 4 ++-- .../terminal/ThreadTerminalRouteScreen.tsx | 17 ++++++----------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index 067ad829b6dac..0fbfbe7a544ac 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -110,7 +110,7 @@ private enum TerminalHardwareKeyEncoder { } } -/// Weak handle for Ghostty callbacks, which can run on the io thread after the view is gone. +/// Ghostty callbacks can fire during deinit and hop to main after it, so they must not retain the view. private final class TerminalViewRef { weak var view: T3TerminalView? } @@ -836,7 +836,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte } private func emitPaste() { - guard !readOnly, let text = UIPasteboard.general.string, !text.isEmpty else { return } + guard let text = UIPasteboard.general.string, !text.isEmpty else { return } onPaste(["text": text]) } diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 733131228d5d1..6c577f591fd3d 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -656,7 +656,7 @@ function ThreadTerminal(props: { return result._tag === "Success"; }; - const pasteFromClipboard = async (readText: () => Promise = Clipboard.getStringAsync) => { + const pasteText = async (readText: () => Promise) => { await pasteSession.paste({ readText, write: writeInput, @@ -679,7 +679,7 @@ function ThreadTerminal(props: { hostPlatform, }); if (resolved.kind === "paste") { - void pasteFromClipboard(); + void pasteText(Clipboard.getStringAsync); return; } void writeInput(resolved.data); @@ -693,11 +693,6 @@ function ThreadTerminal(props: { writeModifiedInput(data); }; - const handleNativePaste = (text: string) => { - setPendingModifier(null); - void pasteFromClipboard(async () => text); - }; - const handleResize = (size: { readonly cols: number; readonly rows: number }) => { terminalDebugLog("native:onResize", { cols: size.cols, @@ -802,9 +797,9 @@ function ThreadTerminal(props: { writeModifiedInput(action.data); }; - const handlePaste = () => { + const handlePaste = (readText: () => Promise) => { setPendingModifier(null); - void pasteFromClipboard(); + void pasteText(readText); }; const handleDismissKeyboard = () => { @@ -973,7 +968,7 @@ function ThreadTerminal(props: { Dialog.alert("No terminal output", "There is no visible output to attach."); }} onInput={handleInput} - onPaste={handleNativePaste} + onPaste={(text) => handlePaste(async () => text)} onResize={handleResize} style={{ flex: 1 }} terminalKey={terminalKey} @@ -1055,7 +1050,7 @@ function ThreadTerminal(props: { handlePaste(Clipboard.getStringAsync)} showChevron={false} />