Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/mobile/modules/t3-terminal/ios/T3TerminalModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
])

Expand Down Expand Up @@ -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")
}
}
}
68 changes: 67 additions & 1 deletion apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:))
Expand All @@ -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 }
Expand Down Expand Up @@ -184,14 +190,16 @@ 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

private let terminalViewport = UIView()
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)?
Expand All @@ -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 }
Expand Down Expand Up @@ -348,13 +357,19 @@ 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)
scrollPanGesture.addTarget(self, action: #selector(handleViewportPan(_:)))
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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 53 additions & 1 deletion apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,26 @@ 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,
initialBuffers: [] as string[],
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();
Expand Down Expand Up @@ -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(
Expand All @@ -97,6 +112,7 @@ function setup(revision: number | null) {
isRunning
onInput={() => {}}
onResize={() => {}}
{...input}
/>,
);
});
Expand Down Expand Up @@ -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<void> | 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] : [],
);
});
4 changes: 4 additions & 0 deletions apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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)}
Expand Down
10 changes: 8 additions & 2 deletions apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -658,9 +658,9 @@ function ThreadTerminal(props: {
return result._tag === "Success";
};

const pasteFromClipboard = async () => {
const pasteFromClipboard = async (readText: () => Promise<string> = Clipboard.getStringAsync) => {
await pasteSession.paste({
readText: Clipboard.getStringAsync,
readText,
write: writeInput,
onReadError: (cause) => {
console.error(new TerminalClipboardReadError({ terminalId, cause }));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/features/terminal/nativeTerminalModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ interface TerminalInputEvent {
readonly data: string;
}

interface TerminalPasteEvent {
readonly text: string;
}

interface TerminalResizeEvent {
readonly cols: number;
readonly rows: number;
Expand All @@ -37,6 +41,8 @@ export interface NativeTerminalSurfaceProps extends ViewProps {
readonly initialBuffer: string;
readonly fontSize: number;
readonly onInput?: (event: NativeSyntheticEvent<TerminalInputEvent>) => void;
/** iOS only: clipboard text from a system paste (edit menu, ⌘V, shortcut bar). */
readonly onPaste?: (event: NativeSyntheticEvent<TerminalPasteEvent>) => void;
readonly onResize?: (event: NativeSyntheticEvent<TerminalResizeEvent>) => void;
}

Expand Down
Loading