What it is
A real macOS terminal emulator with a plain-English bar — type “go to my desktop” and it runs cd ~/Desktop. Real PTY shells (vim, htop, ssh all work), tabs, split panes, search, 7 themes and a command history rail with exit codes. 48 plain-English phrases, a searchable guide, and it always asks before deleting anything. Installs from a 15 MB .pkg; the app is self-contained, so there is nothing else to install.
Version history
Straight from the project’s own card data — what each version added.
Real PTY terminal: tabs, splits, search, themes, history rail with exit codes.
Plain-English bar (48 phrases) + guide tab, confirm-before-destructive, typo rescue.
Self-contained .app bundled into a double-click .pkg installer (Apple silicon).
By the numbers
Measured from the real working folder ~/Documents/projects/apps/BetterTerminal/ — not a claim, a count.
Mostly: Python ×154 · JavaScript ×46 · binary assets ×25 · HTML ×9 · CSS ×8 · Shell ×5
What broke, and what I’d change
Not written yet. This is the part only I can write — what actually went wrong, and what I’d do differently. It lives in builds/reflections/betterterminal.md (see _TEMPLATE.md); as soon as that file has words in it, this section fills in on the next build.
Everything else on this page — the spec, the version history, the file counts — is generated from the project’s own build notes and files, so nothing here is guesswork.
Build spec
The project’s own build notes, rendered. Open the raw .md ↗
Show the spec
BetterTerminal — build spec
A real macOS terminal emulator that's genuinely better than Terminal.app: tabs, split panes, search, themes, a command-history rail with exit codes, jump-to-prompt, and a plain-English bar for people who have never used a terminal. Real PTY login shells — job control, vim/htop/ssh all work.
- App:
~/Applications/BetterTerminal.app(double-click) · fallback~/Documents/BetterTerminal.command - Source:
~/Documents/projects/apps/BetterTerminal/ - Version 1.1.0 · Python 3.11 (uv venv) + aiohttp + ptyprocess + pywebview (Cocoa/WKWebView)
Plain-English layer (v1.1)
web/plain.js is a pure, offline resolver: resolve(text, ctx) -> {ok, cmd, explain, danger}.
- 48 intents in one ordered array, each
{id, group, label, match(n, ctx), build(m, ctx), preview}. - Path resolution (
resolvePath) is the heart:{cwd, listing}context from - Teaching: every translation shows the command plus a glossary explanation
- Safety:
dangerresults (delete/move/copy/kill/install/git push) open a - Guide tab: the same intents rendered as a searchable cheat sheet, with the
- Rescue: exit code 127 +
command not found: Xin the buffer → Levenshtein - Never blocks shell users:
looksLikeShell()runs first — flags, pipes,
Order matters — first match wins, so specific phrases come before generic verbs.
/api/ls lets it match real folder names fuzzily (score by exact/flat/prefix/ substring/token overlap), map synonyms ("desktop" → ~/Desktop), and refuse to invent paths — it returns candidates for a "did you mean" chip instead. Unambiguous home folders win immediately; loose words (photos/music/videos) only map to the system folder when no local folder of that name exists.
(cd = change folder); the History rail keeps the real command.
confirm dialog showing the exact command; the UI never runs them silently.
resolved command or a <placeholder> pattern under each phrase.
suggestion toast with one-click "Run grep" buttons.
paths, quotes, globs and bare known commands pass through untouched.
Architecture
Python owns the terminals; the UI is a web front-end in a native window.
pty_session.py—PtySessionspawns a real login shell viaptyprocessserver.py— aiohttp on 127.0.0.1 with a per-launch random token requiredweb/— xterm.js (vendored locally) +app.js: tab tree, split-pane tree,main.py— picks a free port, runs the server in a thread, opens a pywebview
(own session + controlling tty) and registers the master fd on the asyncio loop with loop.add_reader. Non-blocking read/write with an output buffer, incremental UTF-8 decoding, TIOCSWINSZ resize, killpg for signals.
on /ws and /api/* (no other local page can drive your shell). /ws bridges bytes to a PTY; the first frame carries the real terminal geometry so the prompt never reflows. Also /api/ls (folder listings for the resolver), /api/git, /api/clipboard, /api/open, /api/title, /api/stats.
search (addon-search), WebLinks, OSC hooks, sidebar, palette, English bar.
window at http://127.0.0.1:PORT/?t=TOKEN. Flags: --command, --cwd, --shell, --no-window (headless tests), --port, --token.
Shell integration (the clever bit)
shell/ is a ZDOTDIR shim. zsh reads .zshenv/.zprofile/.zshrc/.zlogin from there; each shim re-sources the user's real file, then .zshrc installs OSC hooks: OSC 7 = cwd, OSC 133;A/B/C/D = prompt start / input start / output start / command finished + exit code. The UI parses these to drive the status bar, the history rail (command text is read back out of the xterm buffer between the B and C marks), and jump-to-prompt markers. OSC 52 (remote clipboard write) is swallowed for security.
Pitfalls learned
(( ${+functions[add-zsh-hook]} ))silently evaluated false in this shell →- Do not default to the xterm WebGL renderer — it painted a blank pane.
- macOS menu bar steals shortcuts: clear
keyEquivalenton conflicting menu - The launcher execs Python, so set
processName+setApplicationIconImage_ - Restarting a shell must invalidate the old WebSocket's handlers (generation
ZDOTDIRalso moves zsh's defaultHISTFILEto$ZDOTDIR/.zsh_history, which- A
matchthat returns a boolean throws away regex capture groups. Use the .hiddenis both an attribute and a class name. The bar is hidden with a- Test the guide against itself: resolve every guide label and assert it
⌘does not always render in terminal fonts — write "Cmd-E" in text sent to
the hooks never installed. Use autoload -Uz add-zsh-hook then add-zsh-hook precmd/preexec, with an array-append fallback.
Default is the DOM renderer; WebGL is an opt-in setting.
items (⌘D dictation, ⌘F fullscreen) after webview.start.
at runtime to brand the Dock/menu.
counter) or its onclose re-marks the new session as dead.
hides the user's real history. Re-export HISTFILE="$HOME/.zsh_history" from the shim's .zshenv unless the user set one.
rx(re, guard) helper so build() always gets a match array; a bare const m = ...; if (m === true) m = null pattern crashes on .replace.
CSS class (classList.toggle('hidden')), so test probes must use classList.contains('hidden') — reading el.hidden silently returns false.
lands on its own intent id. That caught 5 mislabelled/misrouted phrases (get/move stolen by the navigation verb, "go back to the last folder" not matching its own regex).
the terminal, keep ⌘ for the UI chrome.
Rebuild / test
python3 tools/make_icon.py # icon HTML -> PNG -> AppIcon.icns
python3 tools/build_app.py # ~/Applications/BetterTerminal.app + .command
tools/run_tests.sh # both Playwright suites (103 checks) on a temp server
tests/debug.py, tests/debug2.py, tests/debug_keys.py, tests/pty_hooks.py, tests/shim_check.py = targeted debuggers. BT_SNAPSHOT=/tmp/x.png + BT_SNAPSHOT_DELAY=7 saves a PNG of the real WKWebView's pixels (in-process snapshot, no screen-recording permission).