A Command Snippet Library: Changing 'Tap to Execute' to 'View Before Executing'
A Shellby postmortem: a command tool bar at the top of the terminal, tap and it sends. After shipping, that interaction was judged unsafe and refactored into a 'view before executing' snippet library. Plus the pits of building this across six platforms — mobile terminals have no menu-injection hook, macOS's self-drawn context menu gets grayed out by the system, and you can't blindly inject into a full-screen program.
In Shellby, retyping frequent long commands is annoying, and having the AI generate them is too heavy. So there’s a command snippet library. Version one (v1) was a “command tool bar” at the top of the terminal — tap and the command sends. After shipping, I retired it entirely and rebuilt it, because “tap to execute” is a dangerous interaction for ops commands. This post covers that refactor’s reasoning and a string of pits from building it across six platforms.
v1’s problem: tap-to-execute, and the entry was wrong
The v1 tool bar had two problems. One is safety: ops commands blind-sent in one tap — a twitch of the hand and systemctl restart is out, with no chance to “look once and confirm.” The other is mental model: the entry was hidden inside a connected session, yet a “library” feature’s value doesn’t depend on whether you’re currently connected — to manage snippets you shouldn’t have to connect to a server first.
The refactor did two things: replaced the tool bar with a “view before executing” snippet library, and promoted the entry to a first-class entry on par with the host list.
“View before executing” is a core safety design, not a UI preference: the snippet list has no action buttons when collapsed; you tap to expand, see the full command text, and only then do “execute / fill / copy” appear. This prevents mis-taps and makes “copy to use elsewhere” a first-class path. The data model DATA-SNIPPET-01 is deliberately simple too — a global library, {id, name, command, sortOrder, ...}, dropping the originally-planned “snippet bound to host group” association (sparing CloudKit relation migration and cross-stack reference complexity), with all attributes defaulted, no unique constraint, no relations, friendly to sync.
It also divides labor with the “shortcut bar”: the shortcut bar sends keys (esc / ctrl / arrows), the snippet library sends whole commands — two input channels, each its own.
Pit one: you can’t blindly inject into a full-screen program
Execute and fill both inject bytes into the terminal. But if the terminal is currently running a full-screen program (vim / less / htop’s alternate screen), injecting a whole command in necessarily garbles the screen.
So before execute / fill, check for alt-screen, and if so inject no bytes, just give a light hint:
private func execute(_ snippet: Snippet) {
guard !session.isInFullScreenApp else { showBlockedHint(); return }
session.sendText(cmd.hasSuffix("\n") ? cmd : cmd + "\n") // execute: append \n if none
}
private func fill(_ snippet: Snippet) {
guard !session.isInFullScreenApp else { showBlockedHint(); return }
session.sendText(snippet.command) // fill: no newline, you press Enter
}
isInFullScreenApp proxies to the terminal’s isAlternateScreen. Interestingly, this same alt-buffer check guards both snippet execution and the AI Agent’s output echo — “don’t blindly stuff things into a full-screen program” is a general guard, one judgment reused in two places.
Pit two: the mobile terminal has no menu-injection hook
Snippets can be “clip-saved” — saved from a terminal selection, an AI Agent command card, or the clipboard. Desktop is easy: right-click a terminal selection, “save as snippet.” But mobile hit a hard wall: on iOS, SwiftTerm’s UIMenuController menu items are hardcoded empty, with no hook to insert a custom menu item, and the mobile long-press toolbar has none either.
The conclusion is that mobile clip-saving can only go through a clipboard fallback: copy, then “new snippet from clipboard.” This isn’t laziness — that terminal component simply gives no injection point on mobile, so you route around it. There’s a privacy detail too: “new from clipboard” reads the clipboard only at the moment you tap the button, not ahead of time — to avoid frequently triggering the system’s clipboard-access prompt (iOS pops “so-and-so read your copied content”).
Pit three: macOS’s self-drawn context menu gets grayed out by the system
The desktop “right-click selection, save as snippet” wasn’t smooth either. SwiftTerm’s parent menu(for:) returns nil, so you override it to build your own NSMenu. Build it, and the custom menu items are all gray.
Two pits stacked:
menu.autoenablesItems = false // else SwiftTerm's validateUserInterfaceItem
// doesn't recognize the custom selector and grays it all out
// also disable the system-appended AutoFill / writing-tools plugin items:
view.allowsContextMenuPlugIns = false
When autoenablesItems is on by default, AppKit calls validateUserInterfaceItem to ask each menu item whether it should be enabled, and SwiftTerm’s implementation doesn’t recognize our custom selectors, so it disables them all. Turning auto-enable off fixes it. The callback returns through the TerminalSession.onSaveSelection pipe, keeping TerminalKit with zero dependency on the App layer.
Pit four: don’t touch a frozen payload for cross-stack sync
Snippets need to sync across devices. The sync payload originally had 5 base collections, whose byte order was frozen into a deterministic test vector. When adding snippets (and AI config, conversations) as extension collections, the rule is append after the base keys, non-alphabetically, and serialize only the collections actually present — so a payload without snippets has completely unchanged bytes, not breaking the frozen vector. The merge iterates the union of both stacks’ collection keys, so extension collections auto-join the record-level LWW and adding a new type needs no merge-code change. Snippets contain no sensitive values, so they’re not gated by the “config only” sync toggle and sync at any time.
Dual-stack entries, two navigation systems
The same feature lands in different shapes on the two stacks, because their navigation systems differ: on Apple it’s a first-class “command snippets” sidebar row (on par with the host list, with a count), and on iPad/macOS tapping slides out a full-height half-panel from the left, while iPhone pushes a full page; on Flutter it’s a top-level tab on par with hosts / sessions / settings, with a bottom-nav standalone page in compact layout and a NavigationRail + two-pane (snippet library left, resident session right) on large screens.
The feature is consistent, the mental model is consistent (first-class entry, view before executing), but the two ends aren’t forced to look identical — each lands in its platform’s native navigation idiom.
Takeaways
- “Tap to execute” is a bad default for high-risk actions. The snippet library hides action buttons behind “tap to see the full text,” trading one more step for “look once before sending,” and makes “copy elsewhere” a first-class path — safety is often a deliberate extra step of expansion;
- A library feature’s entry shouldn’t depend on context. Managing snippets shouldn’t require connecting to a server first; promoting it to a first-class entry aligns the feature’s value with its mental model;
- Acknowledge a platform’s capability gap, then route around it. The mobile terminal has no menu-injection hook, so go through a clipboard fallback rather than chiseling; that’s not a compromise, it’s respecting the component’s real boundaries;
- One guard can protect multiple places. The alt-screen check guards both snippet injection and AI echo — a general safety judgment is worth extracting and reusing;
- Adding extension data mustn’t touch a frozen serialization. Extension collections appended, serialize only present keys, merge iterating the union — onboarding a feature is adding data, not changing the protocol, and old payloads’ bytes don’t move.
Comments