iOS Terminal UX

Building a Terminal Keyboard Toolbar on iOS

iOS keyboards don’t have Ctrl, Esc, Tab, or arrow keys — but a terminal needs all of them. TermOnMac builds a custom toolbar above the keyboard with a search field, shortcut capsules, and modifier toggles.

Toolbar as inputAccessoryView

The toolbar is attached to the terminal view via inputAccessoryView:

// ios_remote_dev_ios_app/RemoteDevApp/Views/TerminalView.swift
let toolbar = coordinator.buildToolbar()
coordinator.onGearTapped = onGearTapped

tv.inputAccessoryView = toolbar

inputAccessoryView is a UIKit feature that displays a custom view immediately above the keyboard whenever the associated view is the first responder. The toolbar appears with the keyboard, dismisses with the keyboard, and animates with it.

Toolbar Layout

The toolbar contains three areas: a search field on the left, a horizontal scroll view of shortcut capsules in the middle, and a gear button on the right:

func buildToolbar() -> UIView {
    let toolbar = UIView()
    toolbar.backgroundColor = UIColor.systemBackground
    toolbar.frame = CGRect(x: 0, y: 0, width: 0, height: 44)

    // Search field (left, 80pt wide)
    let field = UITextField()
    field.placeholder = "Search shortcuts…"
    field.font = UIFont.systemFont(ofSize: 13)
    field.borderStyle = .roundedRect
    field.autocorrectionType = .no
    field.autocapitalizationType = .none
    field.spellCheckingType = .no
    field.returnKeyType = .done
    // ...

    // Gear button (right, 32pt wide)
    let gearButton = UIButton(type: .system)
    gearButton.setImage(UIImage(systemName: "keyboard.badge.ellipsis"), for: .normal)

    // Result scroll view (middle, fills remaining space)
    let scrollView = UIScrollView()
    scrollView.showsHorizontalScrollIndicator = false

The toolbar height is 44 points — the standard UIKit toolbar height.

autocorrectionType = .no, autocapitalizationType = .none, and spellCheckingType = .no ensure the search field doesn’t try to correct shortcut names like vim or htop.

Search runs 100ms after the user stops typing:

@objc func searchTextChanged(_ sender: UITextField) {
    searchDebounceTask?.cancel()
    let query = sender.text ?? ""

    if query.isEmpty {
        clearResults()
        return
    }

    searchDebounceTask = Task { @MainActor in
        try? await Task.sleep(for: .milliseconds(100))
        guard !Task.isCancelled else { return }
        self.performSearch(query: query)
    }
}

A new keystroke cancels the pending task and starts a new 100ms timer. This avoids running the search function on every character while the user is typing fast.

Capsule Result Buttons

Search results render as capsule-shaped buttons with different colors based on match style:

private func makeCapsuleButton(result: ShortcutSearchResult, index: Int) -> UIButton {
    var config = UIButton.Configuration.filled()
    config.title = shortcut.label
    config.cornerStyle = .capsule
    config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12)

    if isModToggle && isActiveModifier {
        config.baseBackgroundColor = .systemBlue
        config.baseForegroundColor = .white
    } else if isLocalScroll {
        config.baseBackgroundColor = UIColor.systemGreen.withAlphaComponent(0.2)
        config.baseForegroundColor = .systemGreen
    } else {
        switch result.matchStyle {
        case .prefixExact:
            config.baseBackgroundColor = UIColor.systemTeal.withAlphaComponent(0.3)
            config.baseForegroundColor = .systemTeal
        case .categoryMatch:
            config.baseBackgroundColor = UIColor.systemPurple.withAlphaComponent(0.2)
            config.baseForegroundColor = .systemPurple
        case .standard:
            config.baseBackgroundColor = UIColor.secondarySystemBackground
            config.baseForegroundColor = UIColor.label
        }
    }
}

Active modifiers (Ctrl, Alt) are filled blue. Local scroll buttons are green. Search match types use teal/purple/default to indicate match quality.

Modifier Toggle State

Ctrl and Alt are toggle states tracked in the coordinator:

// Modifier key state
var ctrlActive = false
var altActive = false

When tapped, they toggle:

case .modifierToggle(let mod):
    switch mod {
    case .ctrl: ctrlActive.toggle()
    case .alt: altActive.toggle()
    case .shift: break
    }
    rebuildResultCapsules()

The next keystroke from the soft keyboard incorporates the active modifiers — for example, Ctrl + C becomes a single byte sent as 0x03 (the Ctrl-C control character).

Returning Focus to Terminal

After a shortcut button is tapped, focus returns to the terminal so subsequent keystrokes go to the shell:

@objc func searchResultTapped(_ sender: UIButton) {
    // ... handle action
    returnFocusToTerminal()
}

private func returnFocusToTerminal() {
    terminalView?.becomeFirstResponder()
}

Without this, the search field would retain focus and the next keystroke would go into the search box instead of the shell.

Focus Appearance

When the search field is focused, both the search field border and the terminal border are highlighted blue:

func updateFocusAppearance(searchFieldFocused: Bool) {
    if searchFieldFocused {
        searchField?.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.1)
        searchField?.layer.borderColor = UIColor.systemBlue.cgColor
        searchField?.layer.borderWidth = 1.5
        terminalView?.layer.borderColor = UIColor.systemBlue.cgColor
        terminalView?.layer.borderWidth = 2
    } else {
        searchField?.backgroundColor = nil
        searchField?.layer.borderColor = nil
        searchField?.layer.borderWidth = 0
        terminalView?.layer.borderColor = nil
        terminalView?.layer.borderWidth = 0
    }
}

This makes it visually clear which view will receive the next keystroke — important when the toolbar and terminal are visible simultaneously.