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..651c22f75bd03 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,6 +198,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { private let inputField = TerminalInputField() private let focusTapGesture = UITapGestureRecognizer() private let scrollPanGesture = UIPanGestureRecognizer() + private let pasteMenuGesture = UILongPressGestureRecognizer() + private lazy var pasteMenuInteraction = UIEditMenuInteraction(delegate: self) private var lastViewportSize: CGSize = .zero private var lastContentScale: CGFloat = 0 private var lastReportedGrid: (cols: Int, rows: Int)? @@ -208,6 +216,7 @@ 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 } @@ -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,9 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { scrollPanGesture.maximumNumberOfTouches = 1 scrollPanGesture.cancelsTouchesInView = false terminalViewport.addGestureRecognizer(scrollPanGesture) + pasteMenuGesture.addTarget(self, action: #selector(handleViewportLongPress(_:))) + terminalViewport.addGestureRecognizer(pasteMenuGesture) + terminalViewport.addInteraction(pasteMenuInteraction) addSubview(terminalViewport) addSubview(inputField) @@ -466,6 +481,52 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate { } } + @objc + private func handleViewportLongPress(_ gesture: UILongPressGestureRecognizer) { + guard gesture.state == .began, canPaste else { return } + pasteMenuInteraction.presentEditMenu( + with: UIEditMenuConfiguration(identifier: nil, sourcePoint: gesture.location(in: terminalViewport)) + ) + } + + public func editMenuInteraction( + _ interaction: UIEditMenuInteraction, + menuFor configuration: UIEditMenuConfiguration, + suggestedActions: [UIMenuElement] + ) -> UIMenu? { + // The terminal has no text selection, so keep only Paste. The hidden input field + // would otherwise contribute actions such as Scan Text or Writing Tools. + UIMenu(children: Self.pasteCommand(in: suggestedActions).map { [$0] } ?? []) + } + + 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 +777,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/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx index bfaada0294228..8e35b0a6f894d 100644 --- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx +++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx @@ -9,6 +9,8 @@ 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, @@ -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..7ab8ab671a022 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -658,9 +658,9 @@ function ThreadTerminal(props: { return result._tag === "Success"; }; - const pasteFromClipboard = async () => { + 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 +695,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, @@ -971,6 +976,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} diff --git a/apps/mobile/src/features/terminal/nativeTerminalModule.ts b/apps/mobile/src/features/terminal/nativeTerminalModule.ts index ba5b6e4f5d382..7c1e47e40284f 100644 --- a/apps/mobile/src/features/terminal/nativeTerminalModule.ts +++ b/apps/mobile/src/features/terminal/nativeTerminalModule.ts @@ -16,6 +16,10 @@ interface TerminalInputEvent { readonly data: string; } +interface TerminalPasteEvent { + readonly text: string; +} + interface TerminalResizeEvent { readonly cols: number; readonly rows: number; @@ -37,6 +41,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps { readonly initialBuffer: string; readonly fontSize: number; readonly onInput?: (event: NativeSyntheticEvent) => void; + /** iOS only: clipboard text from a system paste (edit menu, ⌘V, shortcut bar). */ + readonly onPaste?: (event: NativeSyntheticEvent) => void; readonly onResize?: (event: NativeSyntheticEvent) => void; }