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.
Debounced Search
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.