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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,24 @@ brew install xcodegen
`gterm.xcodeproj`, `Info.plist`, and `GhosttyKit.xcframework` are generated and
git-ignored.

## Testing SSH startup

Run the Herdr auto-attach regressions on macOS (no SSH credentials or running
Herdr server required):

```sh
xcodegen generate
xcodebuild -project gterm.xcodeproj -scheme gtermSSHTests \
-destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO test
```

These tests exercise the PTY handler's request/reply flow and execute its
startup command with isolated shell profiles and a fixture `herdr` executable.
They cover PATH initialization, request failures, nonzero exit status, clean
detach, and terminal input/output. Channel tests also run in the iOS
`gtermTests` scheme; testing attachment to a real remote Herdr session still
requires a simulator or device smoke test.

## Releasing for AltStore

`scripts/build-altstore-ipa.sh` produces an **unsigned** `.ipa` suitable for
Expand Down Expand Up @@ -96,4 +114,3 @@ Bundled / dependency components keep their own licenses: the
swift-crypto, and swift-nio are Apache-2.0; the OpenAI and SwiftAnthropic SDKs
are MIT.


73 changes: 72 additions & 1 deletion Sources/Ghostty/AccessoryKeyboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import UIKit
///
/// When the user is typing a command, a history-based autocomplete row appears
/// above the key bar; tapping a suggestion sends its remaining characters.
///
/// When the connection attaches with Herdr, a shortcut row is inserted above
/// the key bar. Each button sends the prefix (`ctrl+b`) then the action key
/// so prefix chords do not have to be tapped out on glass.
final class AccessoryKeyboardView: UIInputView {
private weak var target: TerminalSurfaceView?
private var ctrlButton: UIButton?
Expand All @@ -19,6 +23,9 @@ final class AccessoryKeyboardView: UIInputView {
private var suggestionStack: UIStackView?
private var suggestionsVisible = false

private var herdrScroll: UIScrollView?
private var herdrVisible = false

/// History-based suggestions (instant) and the AI suggestion (async). The AI
/// chip is rendered first, distinctly; both insert a full command line.
private var historySuggestions: [String] = []
Expand All @@ -38,12 +45,15 @@ final class AccessoryKeyboardView: UIInputView {
allowsSelfSizing = true
translatesAutoresizingMaskIntoConstraints = false
build()
setHerdrEnabled(target.attachHerdr)
}

required init?(coder: NSCoder) { fatalError("not supported") }

override var intrinsicContentSize: CGSize {
let height = Self.keysHeight + (suggestionsVisible ? Self.suggestionHeight : 0)
var height = Self.keysHeight
if suggestionsVisible { height += Self.suggestionHeight }
if herdrVisible { height += Self.keysHeight }
return CGSize(width: UIView.noIntrinsicMetric, height: height)
}

Expand All @@ -60,9 +70,70 @@ final class AccessoryKeyboardView: UIInputView {
root.bottomAnchor.constraint(equalTo: bottomAnchor),
])
root.addArrangedSubview(buildSuggestionRow())
root.addArrangedSubview(buildHerdrRow())
root.addArrangedSubview(buildKeysRow())
}

// MARK: Herdr shortcut row

/// Show or hide the one-tap Herdr prefix-chord row. Driven by the
/// connection's `attachHerdr` flag via `HerdrSupport.keyboardShortcuts`.
func setHerdrEnabled(_ enabled: Bool) {
let show = !HerdrSupport.keyboardShortcuts(attachHerdr: enabled).isEmpty
guard show != herdrVisible else { return }
herdrVisible = show
herdrScroll?.isHidden = !show
invalidateIntrinsicContentSize()
}

private func buildHerdrRow() -> UIView {
let scroll = UIScrollView()
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.showsHorizontalScrollIndicator = false
scroll.alwaysBounceHorizontal = true
scroll.keyboardDismissMode = .none

let stack = UIStackView()
stack.axis = .horizontal
stack.spacing = 6
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
scroll.addSubview(stack)

NSLayoutConstraint.activate([
scroll.heightAnchor.constraint(equalToConstant: Self.keysHeight),
stack.leadingAnchor.constraint(equalTo: scroll.contentLayoutGuide.leadingAnchor, constant: 8),
stack.trailingAnchor.constraint(equalTo: scroll.contentLayoutGuide.trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: scroll.contentLayoutGuide.topAnchor, constant: 6),
stack.bottomAnchor.constraint(equalTo: scroll.contentLayoutGuide.bottomAnchor, constant: -6),
stack.heightAnchor.constraint(equalTo: scroll.frameLayoutGuide.heightAnchor, constant: -12),
])

for shortcut in HerdrSupport.keyboardShortcuts(attachHerdr: true) {
stack.addArrangedSubview(herdrButton(shortcut))
}

scroll.isHidden = true
herdrScroll = scroll
return scroll
}

private func herdrButton(_ shortcut: HerdrSupport.Shortcut) -> UIButton {
let button = UIButton(type: .system)
var config = UIButton.Configuration.tinted()
config.title = shortcut.title
config.baseForegroundColor = .label
config.cornerStyle = .medium
config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10)
button.configuration = config
button.titleLabel?.font = .systemFont(ofSize: 15, weight: .medium)
button.accessibilityLabel = shortcut.accessibilityLabel
button.addAction(UIAction { [weak self] _ in
self?.target?.sendHerdrShortcut(shortcut)
}, for: .touchUpInside)
return button
}

// MARK: Suggestion row

private func buildSuggestionRow() -> UIView {
Expand Down
43 changes: 28 additions & 15 deletions Sources/Ghostty/TerminalSurfaceView+Link.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import UIKit
import GhosttyKit

/// Tap-to-open-URL support. libghostty detects links (auto-detected URLs and
/// OSC 8 hyperlinks) and opens the one under the cursor on a left-click release,
/// but only treats a cell as "over a link" when the mouse modifiers match
/// `ctrlOrSuper` — which is `super` (⌘) on Apple platforms. iOS has no hover, so
/// a single tap synthesizes a super-modified move + click at the tapped point;
/// if a link is there, libghostty fires the OPEN_URL action (handled in
/// `Ghostty.App.action`), which we route to the in-app browser. A tap on a
/// non-link is a harmless super-click.
/// Tap handling. By default this is tap-to-open-URL: libghostty detects links
/// (auto-detected URLs and OSC 8 hyperlinks) and opens the one under the cursor
/// on a left-click release, but only treats a cell as "over a link" when the
/// mouse modifiers match `ctrlOrSuper` — which is `super` (⌘) on Apple
/// platforms. iOS has no hover, so a single tap synthesizes a super-modified
/// move + click at the tapped point; if a link is there, libghostty fires the
/// OPEN_URL action (handled in `Ghostty.App.action`), which we route to the
/// in-app browser. A tap on a non-link is a harmless super-click.
///
/// When `attachHerdr` is on, the same tap is instead an unmodified left
/// press+release so Herdr's mouse-native TUI can receive the click.
extension TerminalSurfaceView {
func setupLinkTapGesture() {
let tap = UITapGestureRecognizer(target: self, action: #selector(handleLinkTap(_:)))
Expand All @@ -22,12 +25,22 @@ extension TerminalSurfaceView {
// A tap is also the affordance for re-summoning a dismissed keyboard.
showKeyboardIfNeeded()
let loc = gesture.location(in: self)
let superMods = GHOSTTY_MODS_SUPER
// Move with super so the cell registers as over_link, then click it.
ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), superMods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, superMods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, superMods)
// Clear the hover modifiers so the link highlight doesn't stick.
ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), Ghostty.Mods.none.cMods)
switch HerdrSupport.tapDecision(attachHerdr: attachHerdr, x: Double(loc.x), y: Double(loc.y)) {
case .mouseLeftClick(let x, let y):
// Unmodified left press+release at the tap so Herdr (and other
// mouse-mode programs) receive a real click.
let mods = Ghostty.Mods.none.cMods
ghostty_surface_mouse_pos(surface, x, 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)
case .existingGestures:
let superMods = GHOSTTY_MODS_SUPER
// Move with super so the cell registers as over_link, then click it.
ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), superMods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, superMods)
_ = ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, superMods)
// Clear the hover modifiers so the link highlight doesn't stick.
ghostty_surface_mouse_pos(surface, Double(loc.x), Double(loc.y), Ghostty.Mods.none.cMods)
}
}
}
124 changes: 110 additions & 14 deletions Sources/Ghostty/TerminalSurfaceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ protocol TerminalSurfaceViewDelegate: AnyObject {

/// The terminal title changed (OSC 0/2).
func terminalSurface(_ view: TerminalSurfaceView, didChangeTitle title: String)

/// The app (or this view) became visible again after being frozen or
/// off-screen. The session should ask the remote to redraw (window-change).
func terminalSurfaceDidResume(_ view: TerminalSurfaceView, cols: Int, rows: Int)
}

extension TerminalSurfaceViewDelegate {
func terminalSurface(_ view: TerminalSurfaceView, didChangeTitle title: String) {}
func terminalSurfaceDidResume(_ view: TerminalSurfaceView, cols: Int, rows: Int) {}
}

/// A UIView that hosts a libghostty terminal surface (rendered into its
Expand All @@ -42,7 +47,7 @@ final class TerminalSurfaceView: UIView {
isOpaque = true
contentMode = .redraw

// Pinch (two-finger) to change the font size, which reflows the grid.
// Pinch: font size normally; Herdr pane zoom when attachHerdr is on.
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))
addGestureRecognizer(pinch)

Expand All @@ -59,6 +64,14 @@ final class TerminalSurfaceView: UIView {
self, selector: #selector(keyboardWillHide),
name: UIResponder.keyboardWillHideNotification, object: nil
)
nc.addObserver(
self, selector: #selector(appWillResignActive),
name: UIApplication.willResignActiveNotification, object: nil
)
nc.addObserver(
self, selector: #selector(appDidBecomeActive),
name: UIApplication.didBecomeActiveNotification, object: nil
)

guard let app = ghostty.app else {
Ghostty.logger.error("TerminalSurfaceView created without a ghostty app")
Expand Down Expand Up @@ -97,6 +110,22 @@ final class TerminalSurfaceView: UIView {

// MARK: Link opening

/// When true, a tap is delivered as an unmodified mouse left-click so
/// Herdr's mouse-native TUI is reachable, and the accessory bar shows
/// one-tap Herdr prefix chords. Set from the saved connection.
var attachHerdr = false {
didSet {
if !attachHerdr { herdrPaneZoomed = false }
accessory.setHerdrEnabled(attachHerdr)
}
}

/// Best-effort local mirror of Herdr pane-zoom (`prefix+z`). Used so a
/// pinch-out zooms in and a pinch-in zooms out instead of toggling blindly.
/// Desyncs if the user zooms from Herdr's own UI; the next matching pinch
/// or Zoom key resyncs.
private var herdrPaneZoomed = false

/// Called when the user taps a URL in the terminal; the SwiftUI layer routes
/// it to the in-app browser. Set by the hosting view.
var onOpenURL: ((URL) -> Void)?
Expand Down Expand Up @@ -141,13 +170,36 @@ final class TerminalSurfaceView: UIView {

override func didMoveToWindow() {
super.didMoveToWindow()
syncSize()
sizeRenderLayers()
if window != nil {
restoreDisplay()
_ = becomeFirstResponder()
}
}

@objc private func appWillResignActive() {
guard let surface else { return }
ghostty_surface_set_occlusion(surface, false)
}

@objc private func appDidBecomeActive() {
restoreDisplay()
let (cols, rows) = gridSize
delegate?.terminalSurfaceDidResume(self, cols: cols, rows: rows)
}

/// Un-occlude, resize the Metal layer, and force a frame. Needed after
/// iOS suspends the process (IOSurface/drawables go stale) and after the
/// surface is reinserted into the hierarchy.
private func restoreDisplay() {
guard let surface else { return }
ghostty_surface_set_occlusion(surface, true)
syncSize()
sizeRenderLayers()
ghostty_surface_refresh(surface)
ghostty_surface_draw(surface)
ghostty?.tick()
}

override func layoutSubviews() {
super.layoutSubviews()
syncSize()
Expand Down Expand Up @@ -242,22 +294,39 @@ final class TerminalSurfaceView: UIView {
return action.withCString { ghostty_surface_binding_action(surface, $0, UInt(len)) }
}

/// Two-finger pinch adjusts the font size one point at a time. Each ~12%
/// of pinch crosses a step; we reset the recognizer's scale after stepping
/// so a continuous pinch keeps zooming. Changing the font size reflows the
/// grid, which fires the resize callback (-> SSH window-change).
/// Two-finger pinch: font-size steps when Herdr is off; pane zoom
/// (`prefix+z`) when it is on. Font-size changes reflow the grid and
/// fire a resize callback (-> SSH window-change), which fights Herdr's
/// layout, so they are not used on that path.
@objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) {
guard gesture.state == .changed else { return }
let step: CGFloat = 1.12
if gesture.scale >= step {
performBindingAction("increase_font_size:1")
gesture.scale = 1
} else if gesture.scale <= 1 / step {
performBindingAction("decrease_font_size:1")
let phase: HerdrSupport.PinchPhase
switch gesture.state {
case .changed: phase = .changed
case .ended, .cancelled: phase = .ended
default: return
}
switch HerdrSupport.pinchAction(
attachHerdr: attachHerdr,
scale: Double(gesture.scale),
phase: phase
) {
case .changeFontSize(let increase):
performBindingAction(increase ? "increase_font_size:1" : "decrease_font_size:1")
gesture.scale = 1
case .herdrPaneZoom(let zoomIn):
applyHerdrPaneZoom(zoomIn: zoomIn)
case .none:
break
}
}

/// Send Herdr's zoom chord only when the local zoomed flag disagrees.
private func applyHerdrPaneZoom(zoomIn: Bool) {
guard herdrPaneZoomed != zoomIn,
let shortcut = HerdrSupport.shortcut(.zoom) else { return }
sendHerdrShortcut(shortcut)
}

// MARK: Accessory keyboard + sticky modifiers

/// Modifiers "armed" by the accessory bar, applied to the next keypress.
Expand Down Expand Up @@ -297,6 +366,33 @@ final class TerminalSurfaceView: UIView {
clearStickyMods()
}

/// Play a Herdr accessory-bar shortcut: prefix (`ctrl+b`) then the
/// follow-up key, using the same encoder path as typing those keys.
func sendHerdrShortcut(_ shortcut: HerdrSupport.Shortcut) {
clearStickyMods()
for stroke in shortcut.strokes {
sendHerdrStroke(stroke)
}
if shortcut.id == .zoom { herdrPaneZoomed.toggle() }
}

private func sendHerdrStroke(_ stroke: HerdrSupport.Keystroke) {
guard let ch = stroke.character.first,
let (key, charShift) = Ghostty.Key.physical(for: ch) else { return }
let shift = stroke.shift || charShift
if stroke.ctrl || stroke.alt || shift {
var mods = Ghostty.Mods.none
if stroke.ctrl { mods.insert(.ctrl) }
if stroke.alt { mods.insert(.alt) }
if shift { mods.insert(.shift) }
sendKey(key, mods: mods)
} else {
// Bare follow-up after the prefix: typed-key path so it is not
// wrapped in bracketed paste (same reason as tmux prefix + ":").
sendCharacter(stroke.character)
}
}

/// Insert a printable symbol from the accessory bar, applying armed
/// modifiers if any (e.g. Ctrl-/, Alt-|).
func insertSymbol(_ s: String) {
Expand Down
Loading
Loading