Skip to content
Merged
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
43 changes: 42 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,48 @@ connection via a custom **passthru IO backend** added to libghostty. See
Keychain, device-only); each host can select one or more keys to try
- ⏳ Encrypted (passphrase) keys, RSA, richer settings (font/theme)

## iPad and Magic Keyboard

On iPad, Hosts stays in a collapsible sidebar beside the terminal. Use the
Sessions menu to switch connections without disconnecting them. Keys, AI,
and Settings open in sheets; narrow windows collapse to one column.

Magic Keyboard supports two-finger scrollback, pointer drag selection, and
Command-click to open terminal links. The keyboard button hides the software
keyboard while keeping physical keyboard input focused on the terminal.
Hold Command to discover these terminal shortcuts:

| Shortcut | Action |
| --- | --- |
| Command-C / V / A | Copy selection / paste / select all |
| Command-= / - / 0 | Increase / decrease / reset font size |
| Command-Shift-] / [ | Next / previous session |
| Command-W | Return to Hosts, keeping the connection alive |

Control and Option combinations continue to go to the remote terminal,
including Control-C, Control-D, and Option word navigation. Right-click a
host to connect, edit, disconnect, or delete it.

Run the iPad UI tests offscreen (requires `paramiko` in your Python environment
and an installed iOS 26+ simulator runtime):

```sh
python3 scripts/test-ipad-ui.py
```

The runner creates a disposable iPad, boots it without opening Simulator.app,
starts the loopback SSH fixture, and runs all UI tests. It removes its device
and stops its fixture on completion or interruption. Existing simulators are
left alone. Logs and the `.xcresult` bundle (including screenshots) remain in
the printed temporary directory. Use `--derived-data PATH` to reuse a build cache.

Port 62222 must be free for the fixture. The keyboard test exercises an SSH
connection, keyboard dispatch with the software keyboard hidden, session
switching, and rotation. The fixture reports input as hex: Control-C is `03`,
Control-D is `04`, and Option-X is `1b78`. Command shortcuts should not appear
in the SSH input. This is an SSH echo fixture, not a remote shell. Physical
trackpad feel and Magic Keyboard hardware still require a device smoke test.

## Building

Requirements: macOS, Xcode 26+, and the patched Homebrew zig:
Expand Down Expand Up @@ -113,4 +155,3 @@ Bundled / dependency components keep their own licenses: the
[ghostty](https://github.com/madeye/ghostty) engine is MIT; swift-nio-ssh,
swift-crypto, and swift-nio are Apache-2.0; the OpenAI and SwiftAnthropic SDKs
are MIT.

51 changes: 51 additions & 0 deletions Sources/Ghostty/TerminalSurfaceView+Hardware.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,38 @@ import GhosttyKit
/// (mode-aware), while plain printable input is left to `UIKeyInput.insertText`
/// so the layout-correct character is produced and we never double-send.
extension TerminalSurfaceView {
override var keyCommands: [UIKeyCommand]? {
let definitions: [(String, UIKeyModifierFlags, String)] = [
("c", .command, "Copy"), ("v", .command, "Paste"),
("a", .command, "Select All"),
("=", .command, "Increase Font Size"),
("-", .command, "Decrease Font Size"),
("0", .command, "Reset Font Size"),
("w", .command, "Back to Hosts"),
("]", [.command, .shift], "Next Session"),
("[", [.command, .shift], "Previous Session")
]
return definitions.map { input, modifiers, title in
let command = UIKeyCommand(input: input, modifierFlags: modifiers, action: #selector(handleAppCommand(_:)))
command.discoverabilityTitle = title
command.wantsPriorityOverSystemBehavior = true
return command
}
}

@objc private func handleAppCommand(_ command: UIKeyCommand) {
switch command.input {
case "c": _ = performBindingAction("copy_to_clipboard")
case "v": _ = performBindingAction("paste_from_clipboard")
case "a": _ = performBindingAction("select_all")
case "=": _ = performBindingAction("increase_font_size:1")
case "-": _ = performBindingAction("decrease_font_size:1")
case "0": _ = performBindingAction("reset_font_size")
case let input?: onKeyboardShortcut?(input)
default: break
}
}

override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
let unhandled = presses.filter { !handleHardwarePress($0, action: .press) }
if !unhandled.isEmpty { super.pressesBegan(unhandled, with: event) }
Expand All @@ -28,6 +60,25 @@ extension TerminalSurfaceView {
private func handleHardwarePress(_ press: UIPress, action: Ghostty.KeyAction) -> Bool {
guard let uiKey = press.key else { return false }

// UIKit normally dispatches priority key commands before presses.
// Handle the fallback here as well, consuming it exactly once rather
// than letting Ghostty interpret an app shortcut. Shifted bracket
// characters vary by keyboard layout; their physical usages are stable.
let commandModifiers = uiKey.modifierFlags.intersection([.command, .control, .alternate, .shift])
let commandInput: String
switch uiKey.keyCode {
case .keyboardOpenBracket: commandInput = "["
case .keyboardCloseBracket: commandInput = "]"
default: commandInput = uiKey.charactersIgnoringModifiers.lowercased()
}
if let command = keyCommands?.first(where: {
$0.input == commandInput && $0.modifierFlags == commandModifiers
}) {
handleAppCommand(command)
clearStickyMods()
return true
}

var mods = Ghostty.Mods.fromHardware(uiKey.modifierFlags)
mods.formUnion(stickyMods)

Expand Down
1 change: 1 addition & 0 deletions Sources/Ghostty/TerminalSurfaceView+Link.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import GhosttyKit
extension TerminalSurfaceView {
func setupLinkTapGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:)))
tap.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
tap.numberOfTapsRequired = 1
tap.numberOfTouchesRequired = 1
addGestureRecognizer(tap)
Expand Down
53 changes: 53 additions & 0 deletions Sources/Ghostty/TerminalSurfaceView+Pointer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import UIKit
import GhosttyKit

extension TerminalSurfaceView {
func setupPointerGestures() {
let pointerTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)]
let drag = UIPanGestureRecognizer(target: self, action: #selector(pointerDrag(_:)))
drag.allowedTouchTypes = pointerTypes
drag.allowedScrollTypesMask = []
addGestureRecognizer(drag)

let click = UITapGestureRecognizer(target: self, action: #selector(pointerClick(_:)))
click.allowedTouchTypes = pointerTypes
click.require(toFail: drag)
addGestureRecognizer(click)
addGestureRecognizer(UIHoverGestureRecognizer(target: self, action: #selector(pointerHover(_:))))
}

@objc private func pointerHover(_ gesture: UIHoverGestureRecognizer) {
guard let surface = ghosttySurface else { return }
let point = gesture.location(in: self)
let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods
ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.y), mods)
}

@objc private func pointerClick(_ gesture: UITapGestureRecognizer) {
guard gesture.state == .ended, let surface = ghosttySurface else { return }
_ = becomeFirstResponder()
let point = gesture.location(in: self)
let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods
ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.y), mods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}

@objc private func pointerDrag(_ gesture: UIPanGestureRecognizer) {
guard let surface = ghosttySurface else { return }
let point = gesture.location(in: self)
let mods = Ghostty.Mods.fromHardware(gesture.modifierFlags).cMods
if gesture.state == .began {
_ = becomeFirstResponder()
// Recognition starts after the pointer has moved. Anchor the
// selection at the original press rather than the threshold point.
let translation = gesture.translation(in: self)
ghostty_surface_mouse_pos(surface, Double(point.x - translation.x), Double(point.y - translation.y), mods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
}
ghostty_surface_mouse_pos(surface, Double(point.x), Double(point.y), mods)
if [.ended, .cancelled, .failed].contains(gesture.state) {
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
}
}
}
3 changes: 3 additions & 0 deletions Sources/Ghostty/TerminalSurfaceView+Scroll.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import GhosttyKit
extension TerminalSurfaceView {
func setupScrollGesture() {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleScrollPan(_:)))
pan.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
pan.maximumNumberOfTouches = 1
// Magic Keyboard two-finger scrolling and mouse wheels.
pan.allowedScrollTypesMask = .all
addGestureRecognizer(pan)
scrollPan = pan
}
Expand Down
1 change: 1 addition & 0 deletions Sources/Ghostty/TerminalSurfaceView+Selection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ extension TerminalSurfaceView {
let longPress = UILongPressGestureRecognizer(
target: self, action: #selector(handleSelectionLongPress(_:))
)
longPress.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.direct.rawValue)]
longPress.minimumPressDuration = 0.4
addGestureRecognizer(longPress)
}
Expand Down
25 changes: 21 additions & 4 deletions Sources/Ghostty/TerminalSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ extension TerminalSurfaceViewDelegate {
/// CAMetalLayer) driven by the passthru IO backend. Input is sent via the
/// ghostty key/text C API; received bytes are pushed in via `receive(_:)`.
final class TerminalSurfaceView: UIView {
var onKeyboardShortcut: ((String) -> Void)?
private var softwareKeyboardHidden = false
private lazy var hiddenKeyboardView = UIView(frame: .zero)
override var inputView: UIView? { softwareKeyboardHidden ? hiddenKeyboardView : nil }

weak var delegate: TerminalSurfaceViewDelegate?

private weak var ghostty: Ghostty.App?
Expand All @@ -43,6 +48,10 @@ final class TerminalSurfaceView: UIView {
// Non-zero initial frame so the Metal layer has a drawable size.
super.init(frame: CGRect(x: 0, y: 0, width: 800, height: 600))

isAccessibilityElement = true
accessibilityLabel = "Terminal"
accessibilityIdentifier = "terminalSurface"
accessibilityTraits = .allowsDirectInteraction
backgroundColor = .black
isOpaque = true
contentMode = .redraw
Expand Down Expand Up @@ -104,6 +113,7 @@ final class TerminalSurfaceView: UIView {
}
self.surface = surface
setupSelectionGestures()
setupPointerGestures()
setupScrollGesture()
setupLinkTapGesture()
}
Expand Down Expand Up @@ -258,6 +268,10 @@ final class TerminalSurfaceView: UIView {
/// iPadOS keyboard hide key, or the accessory bar's collapse button). The
/// single-tap gesture calls this so tapping the terminal expands it again.
func showKeyboardIfNeeded() {
if softwareKeyboardHidden {
softwareKeyboardHidden = false
reloadInputViews()
}
if !isFirstResponder {
_ = becomeFirstResponder()
} else if !keyboardVisible {
Expand All @@ -272,12 +286,15 @@ final class TerminalSurfaceView: UIView {
/// the whole screen to the terminal. Tapping the terminal or the status-bar
/// keyboard button expands it again.
func collapseKeyboard() {
_ = resignFirstResponder()
// Keep focus so Magic Keyboard input continues with the software
// keyboard hidden. A zero-height input view suppresses only that UI.
softwareKeyboardHidden = true
reloadInputViews()
}

/// Toggle the on-screen keyboard: collapse when visible, expand otherwise.
func toggleKeyboard() {
if isFirstResponder && keyboardVisible {
if isFirstResponder && keyboardVisible && !softwareKeyboardHidden {
collapseKeyboard()
} else {
showKeyboardIfNeeded()
Expand All @@ -288,7 +305,7 @@ final class TerminalSurfaceView: UIView {

/// Perform a libghostty keybind action by name (e.g. "increase_font_size:1").
@discardableResult
private func performBindingAction(_ action: String) -> Bool {
func performBindingAction(_ action: String) -> Bool {
guard let surface else { return false }
let len = action.utf8.count
return action.withCString { ghostty_surface_binding_action(surface, $0, UInt(len)) }
Expand Down Expand Up @@ -334,7 +351,7 @@ final class TerminalSurfaceView: UIView {

lazy var accessory = AccessoryKeyboardView(target: self)

override var inputAccessoryView: UIView? { accessory }
override var inputAccessoryView: UIView? { softwareKeyboardHidden ? nil : accessory }

/// LLM autocompletion engine (lazy: only built when first needed).
lazy var completionEngine = CompletionEngine()
Expand Down
Loading
Loading