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..0fbfbe7a544ac 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -110,6 +110,11 @@ private enum TerminalHardwareKeyEncoder { } } +/// 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? +} + 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. @@ -128,6 +133,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 +148,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 +195,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,13 +203,17 @@ 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 let callbackRef = TerminalViewRef() private var surface: ghostty_surface_t? private var isCreatingSurface = false private var surfaceCreationFailed = false @@ -208,18 +223,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) @@ -253,7 +261,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - refreshSurface() + resetSurface() } } @@ -289,14 +297,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - refreshSurface() + resetSurface() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - refreshSurface() + resetSurface() } } @@ -312,6 +320,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { public required init(appContext: AppContext? = nil) { super.init(appContext: appContext) + callbackRef.view = self applyTheme() clipsToBounds = true @@ -348,6 +357,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 +367,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 +442,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 +499,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 children.isEmpty ? nil : 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() @@ -483,9 +593,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { defer { isCreatingSurface = false } var runtimeConfig = ghostty_runtime_config_s( - userdata: Unmanaged.passUnretained(self).toOpaque(), + userdata: Unmanaged.passUnretained(callbackRef).toOpaque(), supports_selection_clipboard: false, - wakeup_cb: { _ in }, + // 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 ref = Unmanaged.fromOpaque(userdata).takeUnretainedValue() + DispatchQueue.main.async { ref.view?.tickApp() } + }, action_cb: { _, _, _ in false }, read_clipboard_cb: { _, _, _, _, _, _ in GHOSTTY_CLIPBOARD_READ_UNSUPPORTED }, confirm_read_clipboard_cb: { _, _, _, _ in }, @@ -544,12 +660,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { setNeedsLayout() } - private func refreshSurface() { - resetSurface() - createSurfaceIfPossible() - } - 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) @@ -561,6 +674,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { app = nil } + private func tickApp() { + guard let app else { return } + ghostty_app_tick(app) + } + private func applyRemoteBuffer(_ buffer: String) { guard streamedOutput == nil else { return } guard surface != nil else { @@ -591,7 +709,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { output.write(bytes, reset: reset) streamedOutput = output if reset { - refreshSurface() + resetSurface() } else if surface == nil { createSurfaceIfPossible() } else { @@ -608,6 +726,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { 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 @@ -621,15 +740,15 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { 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) } @@ -716,6 +835,11 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { onInput(["data": data]) } + private func emitPaste() { + guard 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. */} + ({ 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(); @@ -63,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, @@ -81,6 +90,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 +111,7 @@ function setup(revision: number | null) { isRunning onInput={() => {}} onResize={() => {}} + {...input} />, ); }); @@ -151,3 +166,16 @@ it.each([null, 1])( } }, ); + +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"]); +}); diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx index 51c82f29fa6ca..35da2feaf6740 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx @@ -55,6 +55,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; } @@ -263,6 +264,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 105e03e3fc0b8..3e6853962a330 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -193,7 +193,6 @@ class TerminalClipboardReadError extends Schema.TaggedError { + const pasteText = async (readText: () => Promise) => { await pasteSession.paste({ - readText: Clipboard.getStringAsync, + readText, write: writeInput, onReadError: (cause) => { console.error(new TerminalClipboardReadError({ terminalId, cause })); @@ -664,7 +662,7 @@ function ThreadTerminal(props: { hostPlatform, }); if (resolved.kind === "paste") { - void pasteFromClipboard(); + void pasteText(Clipboard.getStringAsync); return; } void writeInput(resolved.data); @@ -774,15 +772,14 @@ function ThreadTerminal(props: { return; } - if (action.kind === "paste") { - setPendingModifier(null); - void pasteFromClipboard(); - return; - } - writeModifiedInput(action.data); }; + const handlePaste = (readText: () => Promise) => { + setPendingModifier(null); + void pasteText(readText); + }; + const handleDismissKeyboard = () => { setAccessory((current) => ({ ...current, dismissed: true })); void KeyboardController.dismiss(); @@ -949,6 +946,7 @@ function ThreadTerminal(props: { Dialog.alert("No terminal output", "There is no visible output to attach."); }} onInput={handleInput} + onPaste={(text) => handlePaste(async () => text)} onResize={handleResize} style={{ flex: 1 }} terminalKey={terminalKey} @@ -969,14 +967,19 @@ function ThreadTerminal(props: { onPress={handleShowKeyboard} /> - ) : Platform.OS !== "android" && hasNativeTerminalSurface() ? ( + ) : Platform.OS !== "android" && + !keyboardState.isVisible && + hasNativeTerminalSurface() ? ( { KeyboardController.dismiss(); requestCapture(); }} - className="px-4 py-2" + className="px-4 pt-2" + style={{ + paddingBottom: 8 + (nativeColumnMetrics?.safeArea.bottom ?? insets.bottom), + }} > Attach visible output @@ -1022,6 +1025,12 @@ function ThreadTerminal(props: { ); })} + handlePaste(Clipboard.getStringAsync)} + showChevron={false} + /> ) => void; + /** iOS only: clipboard text from a system paste (edit menu, ⌘V, shortcut bar). */ + readonly onPaste?: (event: NativeSyntheticEvent) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; } 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