agterm
A native macOS terminal with a deliberately small interface and a full control API.
Overview
agterm is intentionally opinionated: rather than scattering shells across tabs, it organizes them into named workspaces, each holding the sessions for one project or context. That hierarchy is the whole model, and there is nothing else to learn before it is useful.
Everything the model holds is also an object a script can address. Almost anything you can do by hand, agtermctl can do over a local socket, and every state it sets it reads back. That is what keeps a screen of concurrent coding agents in order, but nothing here is limited to agents: with nothing scripted at all it is a capable general-purpose terminal.
For the real terminal work — rendering, VT parsing, and shell I/O — agterm embeds Ghostty's engine (libghostty); everything above is agterm's own.
A vertical, two-level sidebar groups sessions under named workspaces.
agtermctl drives almost everything over a local socket.
Split a session into two shells, drop a scratch over it, or run a program in an overlay.
An installable skill teaches Claude Code or Codex to drive agterm through agtermctl.
Tinted glyphs show which of many agents needs you.
Install
Releases are signed and notarized for Apple Silicon (arm64) Macs running macOS 14 or later, so they open without any Gatekeeper workaround.
Homebrew
The cask also installs the agtermctl command-line tool, so cask users shouldn't run the in-app installer as well.
Direct download
Download the latest .dmg from the releases page, open it, and drag agterm.app into /Applications.
Optional Help-menu installers
None are needed to use agterm as a terminal; each connects it to a wider workflow, and you can run any of them later. The first launch on a machine points them out in a welcome dialog, which offers the skill and the status hooks and never appears again.
- ›Install Command Line Tool… puts the bundled agtermctl on your PATH (a symlink in /usr/local/bin) so you can script the app. When that directory is writable by you it links with no prompt; when it is root-owned, which is the case on a clean Apple Silicon Mac, macOS asks once for an administrator password. The Homebrew cask already installs it.
- ›Install Agent Status Hooks… lets a coding agent (Claude Code, Codex, Pi, OpenCode, or others) report its state onto its session's row, so you can tell which of several running agents is active, blocked, or finished.
- ›Install Agent Skill… teaches Claude Code or Codex how to drive agterm through agtermctl, so an agent inside a session can build its own layout, run overlays, and manage windows without you explaining the API. It drives the app through the CLI, so install that one too. The same skill is also published as a plugin from the repository — claude plugin marketplace add umputun/agterm then claude plugin install agterm@agterm, or the matching codex plugin commands. Use one route or the other, not both: a machine with two copies of the skill leaves it undefined which one the agent picks. Adding the marketplace clones this whole repository, which is an app rather than a skill library; both CLIs take --sparse on marketplace add to limit the checkout, and it has to cover the marketplace manifest as well as plugins/agterm.
Build from source
Requires macOS 14+, Xcode 26 with xcodegen on PATH (plus its Metal Toolchain), and Homebrew.
$ scripts/setup.sh
# generate the Xcode project, build Debug, launch
$ scripts/run.sh
# host-free unit tests, no Xcode needed
$ cd agtermCore && swift test
The app bundles the CLI, but it also lives in the agtermCore Swift package and builds standalone, without Xcode or libghostty:
# binary at agtermCore/.build/release/agtermctl
Workspaces & sessions
A session is one running shell with a name, a working directory, and its own scrollback — the unit you work in and the row you see in the sidebar. A workspace is a named group of sessions for one project or context. Sessions can move between workspaces while still running, keeping their shell and scrollback.
- ›Two-level sidebar tree: workspaces, each containing sessions. Each row carries a leading kind icon — a filled folder for a workspace, an outlined terminal for a session.
- ›Default session name is the basename of its working directory. Renaming pins a custom name; clearing it reverts.
- ›Add workspaces and sessions, and toggle the workspace filter and the flagged view, from the four-icon bar at the bottom of the sidebar. Rename inline (double-click a row). Copy Name on a session or workspace row copies its name to the clipboard. Duplicate Session from a single session's context menu for a fresh shell in the same workspace, right after it, in its current directory — only the directory carries over. Shift-click and ⌘-click select multiple sessions for batch flag, close, move, and drag. Right-clicking inside the selection keeps the whole batch, so Flag, Close, and Move to act on all of it; right-clicking a row outside narrows the menu to that row. Dragged sessions keep running, shell and scrollback intact.
- ›Drag up to 20 folders from Finder onto the tree to open sessions there. A drop on a row lands in that row's workspace; a drop on empty sidebar space uses the current workspace, or the focused one when the workspace filter narrows the tree to exactly one. Collapsed workspaces spring open while hovering and close again on cancel. Reveal a session's focused-pane directory in Finder from its context menu; the item is greyed out when that directory no longer exists. Folder-picking panels (Open Directory… and the Settings directory choosers) open in the focused pane's directory, or in the directory already configured where there is one, falling back to your home directory.
- ›Narrow a crowded sidebar two ways: flag sessions across workspaces into a flat working-set view (a flag is durable and survives a move), or focus a set of workspaces to collapse the tree to just their sessions. Focus one workspace to zoom to it, or mark several from their row menus and apply the filter once with the sidebar's grid button — marking never narrows the tree on its own, and suspending the filter keeps the set for one click back. Creating a workspace while the filter is on adds it to the set, and selecting a session outside the set suspends the filter rather than discarding it. The set is per-window and remembered across restarts. If narrowing the sidebar would hide the session you are working in, agterm moves you to the most recent session still on screen, unless the narrowing leaves nothing visible at all. The two are independent.
Terminals: split, scratch, quick, search, overlay
A session can split into two shells side by side with ⌘D or top and bottom with ⌘⇧D. Both panes share one sidebar row: a split is one session with two terminals, not two sessions. Using the other shortcut transposes a shown split; using its current shortcut hides it without closing the second shell. The divider follows the current axis and remembers its position; drag it to resize the panes or double-click it to return to an even split. Close Split in the action palette tears the pane down whatever it is running.
A single throwaway shell for the whole app, not tied to any session or window. It appears in a floating panel at
90% of whichever screen has focus, on the Space you are currently on, and opens in the active session's
directory, sized to 90% of that screen up to a comfortable maximum. Bind a system-wide chord with
global-hotkey in keymap.conf and it
summons agterm from any application. Press the key again, or click away, to dismiss it; hiding keeps its shell
alive. A panel opened by agtermctl quick show stays put instead of closing
on click-away, so a script can drive it. Not restored across launches.
An extra shell belonging to one session, for a quick aside next to your main work. It covers the session full-screen and hides again without killing it. While it is up, ⌘D and the split button hide it rather than rearrange the panes beneath. Opens in the session's directory; not restored across launches.
A search bar at the top of the focused terminal highlights matches in live scrollback with an "N of M" counter. Enter steps forward, Shift-Enter back, Esc closes.
Runs one program in a temporary terminal over a session and disappears when the program exits, leaving the session unchanged. Mostly driven from the control API to launch an interactive program (a diff viewer, a process monitor) — full-size, as a floating panel, or scoped to one split pane while the sibling pane stays live. The same slot also holds a HUD, a small passive panel carrying a message instead of a program, which leaves the session focused and typable underneath. See the agtermctl reference.
Fills the whole window with one terminal surface — a pane, the scratch, or an overlay —
hiding the sidebar and collapsing the title bar to a slim strip with the traffic lights, the window title, and an exit button
(⌘W leaves zoom too). A view mode, not a layout change: exiting
restores split ratios, focus, and visibility exactly as they were, and everything else keeps running behind it.
Scripts can zoom any surface by id with agtermctl surface zoom. Distinct
from macOS window zoom and full screen, which size the window itself.
Watch several agents or builds at once: sessions' live output in one grid overlaid on the window. The cell unit is a
session+pane — a non-split session is one cell, a split session is two (its left and right panes), capped at nine
cells. Each cell's name chip also reflects the session's agent status, filling with the status color and pulsing
while it blinks, unless macOS Reduce Motion is enabled; the status color and text remain visible without the
repeating animation. It is view-only — no cell's terminal
takes input; the arrow keys move a highlight, Enter (or a single mouse click on a cell) jumps into that
session and focuses that exact pane, and Esc closes. Opened over the control channel with
agtermctl dashboard <ids…>, or
agtermctl dashboard --mru to fill it from the window's most-recently-used
sessions instead of naming ids. An id may carry a :left/:right
suffix to place one pane instead of the whole session — the same form
tree --json reports in dashboardMembers — so
agtermctl dashboard "$a:left" "$b:right" keeps the panes you did not ask for
out of the nine-cell budget. The most-recently-used grid also has a built-in opener —
⌘⇧G (or Navigate ▸ Dashboard, the command palette's Dashboard, or the
title-bar grid button) opens it auto-sized. Cell fonts size absolutely
(--font-size) or scale to the grid
(--auto-size). Mutually exclusive with terminal zoom.
Windows
A window is a top-level bundle of workspaces and sessions in its own on-screen macOS window, with its own sidebar and its own sessions — so "work" and "personal" can run as two separate windows at once, each with its own tree. Keep a library of windows, open one per screen, and create, rename, or delete them from the File menu (New Window ⌥⌘N) or the action palette. The set of windows open at quit reopens on the next launch, frames restored. Windows are also fully scriptable — agtermctl window can create, raise, move, resize, and minimize them, so a few lines of shell can give every window the same frame and park all but the one you are on, turning several windows into what feels like one that switches contents.
Notifications
A program in any session can raise a desktop notification (via OSC 9 / 777, or agtermctl notify). It surfaces as a macOS banner and an unseen-count badge on the sidebar row (rolled up onto a collapsed workspace); clicking the banner brings agterm forward and focuses the exact pane that raised it, and focusing a session clears its badge — or clear it headlessly with agtermctl session seen so an orchestrator driving a session over the socket can acknowledge it without pulling focus (agtermctl tree --json reports each session's unseen count). Banners and count badges toggle independently in the Notifications settings, which also sets the Dock-icon bounce for a background notification (off, once, or until you focus agterm) and an optional notification sound (a system sound attached to each delivered banner; None by default, silenced by Do Not Disturb). For a coding agent that just needs to say it is waiting, agent status is usually the better fit.
Accessibility
Voice dictation tools that probe for a focused text field — the system Dictation, MacWhisper, and similar assistive apps — engage over the terminal: the on-screen pane advertises itself to the accessibility system as an editable text area, so a hold-to-dictate widget anchors to it and dictated text lands at the prompt. Text arrives at the cursor the same way typing does, and an insert carrying a newline, a tab, or any other control character goes in as a bracketed paste, so a program that accepts bracketed paste takes it as literal text instead of running the line or completing the word. That last part is the same caveat ⌘V carries: at a raw prompt with bracketed paste off, a trailing newline still submits and a tab still triggers completion.
Two limits are worth knowing. The scrollback is deliberately not mirrored, so the exposed pane reads to a screen reader as an empty text area named "Terminal" rather than as the terminal's contents — mirroring the grid is a separate, much larger piece of work. And because the terminal appends at the cursor with nothing to read back, a dictation tool that re-sends its whole transcription on every revision (rather than only the new words) will concatenate its drafts at the prompt; tools that insert incrementally, MacWhisper among them, are unaffected.
Customization & settings
A live-preview theme picker (View ▸ Select Theme…, or the action palette) applies each of the 512 bundled themes to the open terminals as you move through the list — Enter commits and syncs it to Settings, Esc reverts to the one you started on. Settings (⌘,) has six tabs, and changes apply live:
agtermctl reference
Looking for the full list? Every command, with its arguments, return values, and errors, lives on the Command reference →
agterm can be driven from a script over a local unix-domain socket through the companion CLI, agtermctl. This is for fire-and-forget scripting that manages workspaces and sessions, injects text, invokes control actions, and polls a control-event feed for status and lifecycle changes. There is no terminal-output streaming.
To open a terminal at a directory without the CLI, open -a agterm <path> — or right-click a folder in Finder and choose Open With ▸ agterm. agterm adds a session in that directory to the last-active window. This works when agterm is already running (its usual state); if it isn't, launch agterm first, then run the command. The socket equivalent, and the way to place the session precisely, is agtermctl session new --cwd <path>.
Each command targets a session or workspace by its UUID, a unique prefix of that UUID (git-style), or the keyword active (the selected session / current workspace). --target defaults to active, so the current one rarely needs naming. --target/--workspace take an id, a prefix, or active — never a name. For workspaces, active is the one a new session lands in: a workspace you just created in the foreground, otherwise the selected session's, otherwise the last one. A newly created workspace stays the target until the selection changes — to a different session or to none — or until you delete it or hide it behind the workspace filter. workspace select moves the target to the workspace you name, an empty one included. So New Session (and session new) right after creating one lands in the new workspace rather than the one you came from. Mutating commands normally print the affected id; batch session close and session move accept repeated --target options and print the number of sessions actually changed. Add --json for the raw response, or --socket PATH to override the socket. The exit code is zero on success, non-zero on error.
Native picker
agtermctl pick reads nonblank lines or a JSON array of {id,label,subtitle?} objects from stdin and opens agterm's fuzzy picker. It blocks by default and prints the picked, custom, or cancelled result as bare JSON. Add --no-block to get the picker id immediately, then use pick result or pick cancel. The target tree's top-level pickPending field carries that id while the picker waits.
Typing matches item labels only; a subtitle is shown but never searched, so consequence text on one row cannot filter out its safer neighbour. An empty query lists the items in the order they were supplied, so the caller's first item is the one Return runs on open. --query TEXT prefills the field and filters immediately, which ranks by match score and therefore does not preserve that order. With --allow-custom the item list may be empty, which turns the picker into a plain text prompt: the custom row appears as soon as the query is nonblank, whether prefilled by --query or typed. An itemless call still reads stdin, so redirect it (< /dev/null) or it blocks.
agtermctl pick --allow-custom --query "$name" --prompt "Rename to" < /dev/null
id=$(printf '%s\n' alpha beta | agtermctl pick --no-block | jq -r '.id')
agtermctl pick result "$id"
agtermctl pick cancel "$id"
agtermctl tree --json | jq -r '.result.tree.pickPending // empty'
Sessions & workspaces
agtermctl tree
# create a workspace, capture its id, open a session in it
ws=$(agtermctl workspace new work)
agtermctl session new --workspace "$ws" --cwd ~/src/agterm
# run a command as the session's process (argv-style; wrap in sh -c for shell syntax)
agtermctl session new --command "ssh user@host"
agtermctl session new --command "zsh -lc 'make test'" --wait # hold open after the command exits (press any key to close); needs --command
agtermctl session new --name myhost --workspace-name servers --create-workspace
# step / reorder / relocate
agtermctl session go --to next # next|prev|first|last
agtermctl session move --to up # reorder: up|down|top|bottom
agtermctl session move "$ws" # relocate to a workspace
agtermctl session new --after active # create right after the current session (--before to precede)
agtermctl session new --cwd ~/src/agterm --no-select # create in the background without switching to it
agtermctl session duplicate --target 9f3c # a fresh shell in that session's workspace and cwd, right after it
agtermctl session move --after 9f3c # place after an anchor (its workspace is used; relocates cross-workspace)
agtermctl session move "$ws" --target 9f3c --target abcd # move a batch as one ordered block
agtermctl session close --target 9f3c --target abcd # one grace-period undo for the batch
agtermctl workspace go --to next # step to the next workspace and select its first session
agtermctl workspace move --to top # reorder a workspace
agtermctl workspace new work --collapsed # create a workspace closed in the sidebar (fill with session new --no-select)
agtermctl workspace collapse --target 9f3c # collapse one workspace; workspace expand re-opens it
agtermctl workspace focus on # mark this workspace alone and apply the filter (on|off|toggle|add)
agtermctl workspace focus add --target a1b2 # mark another one without narrowing the tree yet
agtermctl workspace filter on # apply the marked set; filter off suspends it without losing the set
agtermctl session reveal --target 9f3c # reveal the focused pane's cwd in Finder
Typing, selection & text
agtermctl session type --target 9f3c $'make test\n'
echo 'make test' | agtermctl session type --stdin
# route to a pane: left (default) | right (split) | scratch (even when hidden)
agtermctl session type --pane right $'ls\n'
# type into a background session without changing focus
agtermctl session type --target "$id" $'echo hi\n'
# read a pane's selection (does not touch the clipboard) or its output
sel=$(agtermctl session copy --target 9f3c)
agtermctl session text --pane scratch --target 9f3c
# paste the system clipboard (⌘V), or select the whole buffer (⌘A) then copy it
agtermctl session paste --target 9f3c
agtermctl session select-all --target 9f3c
Splits, scratch, quick & search
agtermctl session split close # vertical=left/right; horizontal=top/bottom
agtermctl session resize --split-ratio 0.7 # primary fraction; grow role/position aliases also work
agtermctl session scratch toggle # on|off|toggle
agtermctl session flag on # on|off|toggle|clear
agtermctl session seen --target 9f3c # clear the unseen badge, focus-free
agtermctl sidebar mode flagged # tree|flagged|toggle
agtermctl quick toggle # show|hide|toggle
agtermctl quick type 'ls -la' # or --stdin; quick text reads it back
agtermctl surface zoom # fill the window with the active surface (show|hide|toggle; --target surface:<id>:right)
agtermctl dashboard "$a" "$b" "$c" --auto-size # view-only grid; a split session is two cells, capped at 9 panes (--mru; --font-size N | --auto-size; --close)
agtermctl font inc # main pane font size
agtermctl font dec --pane right # just the split pane (left|right|scratch)
# open the search bar, print the "N of M" counter
agtermctl session search "error"
agtermctl session search --next # --prev | --close
Overlays
agtermctl session overlay open "revdiff HEAD~3" --target 9f3c
# floating framed panel at 70% of the pane, optionally tinted
agtermctl session overlay open "htop" --size-percent 70
agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --background-color "#2a1a3a"
# switch the user to the target as the overlay opens
agtermctl session overlay open "revdiff HEAD~3" --size-percent 80 --target 9f3c --follow
# keep it open after exit, or block until it exits and inherit its status
agtermctl session overlay open "make test" --wait
agtermctl session overlay open "make test" --block
# resize an open overlay in place — floating percent, or back to full (the program keeps running)
agtermctl session overlay resize --size-percent 60 --target 9f3c
agtermctl session overlay resize --full --target 9f3c
agtermctl session overlay close --target 9f3c
agtermctl session overlay result # last overlay's exit status
# cover only one split pane, leaving the sibling pane live
agtermctl session overlay open "revdiff HEAD~3" --target 9f3c --pane right
# read what is INSIDE the overlay — the selection the user made, or its drawn screen
agtermctl session overlay copy --target 9f3c --pane right
agtermctl session overlay text --target 9f3c --pane right
--wait keeps a "press any key to close" prompt so you can read the final output; --block reports only the exit status (the overlay never captures stdout — a TUI writes its own result file). By default an overlay opens on its target without switching the active session (full and --size-percent floating alike); --follow switches the user to the target. session overlay resize changes an open overlay in place — --size-percent makes it floating, --full switches it back — without restarting the program. A (overlay) tag in agtermctl tree marks a session whose overlay is open.
session overlay copy returns the selection made inside the overlay and session overlay text its terminal buffer. These exist because session copy and session text address the pane the overlay covers: text you highlight in an overlay reads there as no selection, and session text --pane right hands back the shell underneath. Reach for them when the read is not chord-driven: an agent polling from outside, or a script wanting the selection some time after the fact. A chord already receives the selection of the surface it fired in, the overlay included, as $AGT_SELECTION, so a custom command should use that rather than a later socket read. The buffer one returns a TUI's screen as drawn, wrapped where it wrapped — for a program's real output, still read the file it writes.
--pane left|right scopes the overlay to one split pane instead of the whole session: it covers exactly that pane and leaves the sibling pane visible and interactive, so an agent working in the split can show a diff or a TUI over its own pane without blanking the one you are reading. The two panes are independent and may both hold an overlay at once, each with its own background color. A pane overlay is always full-pane — there is no floating variant, so --pane cannot combine with --size-percent and session overlay resize takes no --pane — and everything else matches the session-wide overlay. A non-split session accepts --pane left, since its shell reports AGTERM_PANE=left, so a script can pass --pane "$AGTERM_PANE" without checking whether the session is split. A pane that is not on screen is refused with pane not visible; hiding the split after opening is fine, the program keeps running and reappears when the split comes back. close, result, copy and text take the same --pane, ⌘W dismisses the focused pane's overlay before it would close the session (one on the other pane is not in front of you, so ⌘W keeps its ordinary meaning), and agtermctl tree --json reports the covered panes as paneOverlays.
Message panel (HUD)
agtermctl session hud "gathering options…" --spinner --detail "scanning branches" --target "$AGTERM_SESSION_ID"
# repaint in place, no re-spawn and no blink
agtermctl session hud update "ready" --detail "pick a branch" --target "$AGTERM_SESSION_ID"
# anchor it and set its width by hand instead of measuring the message
agtermctl session hud "deploying" --position top-right --size-percent 30
# color both halves of the panel
agtermctl session hud "deploying" --text-color "#7ec07e" --background-color "#202020"
agtermctl session hud close --target "$AGTERM_SESSION_ID"
A HUD carries a message rather than a program, and it is passive: the session keeps first responder and stays typable under the panel, the terminal behind it is neither dimmed nor click-blocked, and nothing waits for an answer. It is for the seconds a script or an agent needs before it can show anything, so the session says what is happening rather than sitting silent. open is the default subcommand, so session hud "…" posts one. --position anchors it to any of the nine positions session background takes — top-left, top-center, top-right, center-left, center, center-right, bottom-left, bottom-center, bottom-right — defaulting to center, with every anchor off center holding a margin off that pane edge on its own; a corner is what keeps a panel out of the text you are reading. The bare top and bottom this took before are still accepted for the middle column. The panel comes from the message on both axes — width from the longest line, height from the number of them, so a title and a subtitle give a wide, short panel rather than a square one. --size-percent overrides the width only, bounded to at most 80% of the pane, so a message never covers the session it is about. --text-color sets the text's own color and --background-color the panel's backing, so the two halves are independent. An update replaces the whole message, so --detail, --spinner and --text-color have to be repeated to survive it. The spinner takes a style — --spinner-style bar|braille|circle|blocks|dot|none, which turns it on by itself, with dot blinking rather than animating — and an update may switch style in place; --background-color is the exception, read once when the panel is created and kept — in the panel and in the read-back — across every update, which is why an update does not take it at all.
It shares the overlay slot, so a second hud replaces the first, session overlay open replaces a HUD, and ⌘W or session overlay close takes one down; a HUD over a running program is refused instead, because a message is replaceable and a program is not. session overlay result over a HUD errors, and session overlay resize accepts a percent but refuses --full, which would cover the session the message is about. agtermctl tree --json reports the panel as the session node's hud object, with overlay reading false beside it; there is no event, so a script that needs the state polls the tree.
Windows
w=$(agtermctl window new work)
agtermctl window new proj-b --minimized # create one, parked in the Dock
agtermctl window select "$w" # raise it (opening if closed)
agtermctl window rename "$w" personal
agtermctl window minimize "$w" on # park it in the Dock (off restores)
agtermctl window close "$w" # close its window (bundle kept)
agtermctl window delete "$w" # delete (last window can't be deleted)
# --window targets a specific window's tree on session/workspace/tree/font
agtermctl tree --window "$w"
agtermctl session new --window "$w" --cwd ~/src/agterm
Keymap, config & notify
agtermctl config reload # re-read the ghostty config, returns the diagnostic count
agtermctl notify --title Build --body "tests passed"
The cookbook collects complete agtermctl workflows, each in its own directory with a README and, where it needs one, its scripts: switching the sidebar to a single project, closing a project's workspaces and bringing them back later, picking a path with fzf and typing it into the shell, and giving each tab its own Claude Code or Codex conversation across a restart. They are written to be copied into your own setup and edited, not only read; cookbook/CONTRIBUTING.md has the rules for adding one.
Recipes come from other people as well as the maintainer. Every one is reviewed before it is accepted, but they are shell scripts you run on your own machine against your own sessions, and several close sessions or delete workspaces, so read a recipe before you run it.
Customizing keys
agterm reads a user-editable, kitty-flavored keymap file at ~/.config/agterm/keymap.conf. It rebinds built-in menu shortcuts and defines custom shell commands bound to keys (and listed in the action palette). The file is optional — a commented starter is written on first launch, and the directory can be changed in Settings ▸ Key Mapping. Three verbs; blank lines and # comments are ignored.
map cmd+shift+l toggle_split
map ctrl+shift+k command_palette
# or to several alternatives, joined by | with no spaces around it
map cmd+t|ctrl+a>t toggle_scratch
# define custom commands ("name" shows in the palette; chord optional)
command "Open in Zed" cmd+shift+e open -a Zed {AGT_SESSION_PWD}
command "Lazygit" ctrl+a>g agtermctl session overlay open 'zsh -lc lazygit' --socket {AGT_SOCKET}
command "Deploy" ./deploy.sh
# one system-wide chord that summons the quick terminal from any application
global-hotkey ctrl+opt+space
A chord is modifier words (ctrl, cmd, opt, shift) joined by + and a base key (a single character or tab/space/return/delete/left/right/up/down). A Shift-typed symbol is written shift+<base> (e.g. shift+/ for ?, shift+= for +) — the base key, not the shifted symbol. Custom commands may also use a leader sequence (ctrl+a>g), and their chord must include a modifier — a bare key can't shadow a plain terminal key.
global-hotkey takes exactly one chord and binds it system-wide: it summons the quick terminal while any application is frontmost, which no other binding here can do — the rest only fire while agterm has the keyboard. It must carry a modifier, takes no alternatives and no leader sequence, and a second global-hotkey line replaces the first. macOS registers it by physical key position, so it keeps firing on a non-Latin layout. Because the system owns it rather than agterm, it takes no part in the collision rules below. Note which side wins: the system-wide chord takes the key even while agterm is in front, so binding one a menu item already uses means that menu shortcut stops firing. It is unset unless you add the line.
One binding can offer several alternatives, joined by | inside a single token with no spaces around it — map cmd+t|ctrl+a>t toggle_scratch fires the action from either, and a command takes alternatives the same way. For a built-in, the first single-chord alternative the menu can carry becomes the menu shortcut; every other alternative, on either verb, is delivered by a key monitor and so must carry a modifier on its first chord. This is also how a built-in gets a leader sequence, because a menu item holds exactly one key equivalent: map ctrl+a>s toggle_split binds the sequence and leaves the action with no menu shortcut at all — its shipped ⌘D is gone rather than kept, and free for another action to claim. A typo in one alternative rejects the whole line, so a mistake can't hide behind a line that half worked; an alternative that merely breaks a rule or collides with an existing binding drops on its own and its siblings keep firing. If a line ends up binding nothing at all, the action simply keeps the shortcut it shipped with.
Chords are written in Latin and keep working on a non-Latin keyboard layout. A layout that cannot type ASCII — Russian, Greek, Hebrew, Arabic, Thai — resolves every chord by the physical key position, so cmd+o still fires on the key marked O even though it types щ. A layout that can type ASCII binds what it types, so an alternative Latin layout keeps its own letter positions: on Dvorak, cmd+o follows the O you actually type.
Bindable built-in actions
new_workspace rename_workspace delete_workspace
new_session open_directory rename_session duplicate_session
close_session reopen_recent undo_close clear_status
increase_font_size decrease_font_size reset_font_size
toggle_split toggle_horizontal_split toggle_scratch toggle_search
toggle_sidebar toggle_flag toggle_flagged_view
focus_left_pane focus_right_pane focus_workspace toggle_workspace_filter
toggle_workspace_collapse
previous_session next_session first_session last_session
previous_workspace next_workspace
previous_attention_session next_attention_session
quick_terminal session_palette command_palette
custom_command_palette show_attention
select_theme toggle_fullscreen toggle_terminal_zoom
dashboard
toggle_fullscreen is the one action with no menu item of its own. macOS adds an "Enter Full Screen" item to the View menu whenever that menu is drawn, and nothing suppresses it, so an item of agterm's own would sit beside it as a duplicate. The binding (⌃⌘F by default) is handled directly instead, which is why the menu entry advertises the system's Globe+F rather than your chord. Both work, and rebinding toggle_fullscreen changes the chord as usual — it just won't show up next to that menu item.
Command tokens
{AGT_WORKSPACE_ID} {AGT_WORKSPACE_NAME}
{AGT_WINDOW_ID} {AGT_WINDOW_NAME}
{AGT_PANE} {AGT_SELECTION} {AGT_SOCKET}
Tokens expand at fire time (also exported as $AGT_* env vars on the spawned process). A token is substituted raw into the shell line, so for content you don't control — {AGT_SELECTION}, and also {AGT_SESSION_NAME}/{AGT_SESSION_PWD} (a remote host can set these via OSC) — prefer the matching quoted env var, e.g. "$AGT_SELECTION".
Open the file with File ▸ Edit Keymap… (or the ⌃⇧P palette): it opens in a 95% overlay running $VISUAL/$EDITOR (falling back to vi) and reloads on quit. Apply edits made elsewhere with File ▸ Reload Keymap or agtermctl keymap reload. A malformed line never discards the rest — it surfaces in the diagnostics list in Settings ▸ Key Mapping while the good lines still apply.
To check what is actually bound, agtermctl keymap list prints every built-in with the binds it resolved to (the menu shortcut first, then any alternatives, joined with |), the custom commands, each diagnostic in full, and the key equivalents the menu bar is really carrying. If a binding will not fire, compare the last two: an action whose chord no menu item holds is usually a menu problem, not a keymap one. Only the menu shortcut can appear there — an alternative never does, and neither do undo_close (⌘Z) and toggle_fullscreen (⌃⌘F), which a key monitor delivers rather than a menu item.
v1 limitations
- ›A built-in's menu shortcut is single-chord only: a leader sequence binds through the key monitor instead, as an alternative, and so never shows next to its menu item.
- ›A map line can't bind a bare, modifier-less arrow — a built-in rides an always-on menu key equivalent, so a bare arrow would swallow the key in the terminal, the palettes, the dashboard grid, and every text field; any modifier makes it bindable. The literal + and > can't be a bare key token (they are the separators), but those keys are bindable as shift+= and shift+.; only increase_font_size's default ⌘+ shows as a glyph because its stored form doesn't round-trip through the file.
- ›The Ctrl-Tab MRU switcher and Ctrl-1/Ctrl-2 pane focus are not rebindable yet.
- ›Shortcut hints are not written the same way for both verbs. A built-in shows macOS glyphs (⌘⇧E), with every alternative listed space-separated and a sequence's chords joined by > as in the file (⌘T ⌃A>T), in the palette and in the toolbar and sidebar tooltips alike. A command shows the raw kitty spelling you typed instead (cmd+shift+e), alternatives still joined by |.
Ghostty config
agterm builds its terminal config from four sources, each overriding the one before it:
(lowest) (global, OFF by default) (agterm-scoped) (UI wins)
agterm is self-contained: by default it does not read your global ~/.config/ghostty/config, so a config written for the standalone Ghostty.app never silently changes agterm. Turn on Settings ▸ General ▸ Use my global Ghostty config to fold it in.
<config dir>/ghostty.conf (default ~/.config/agterm/ghostty.conf) is the place to customize agterm. It sits next to keymap.conf and gets the same treatment: a fully commented starter file is written on first launch, so a fresh one changes nothing. It is always loaded, and is scoped to agterm so the standalone Ghostty.app never reads it. Put any ghostty config key there — the keys agterm manages from Settings (font, theme, opacity, blur, scroll speed) still win. A common use: macos-option-as-alt = true. The full key reference is at ghostty.org/docs/config.
A keybind here follows ghostty's own rules, which differ from keymap.conf: a bare letter or digit binds the character the active layout produces, so keybind = super+opt+ctrl+g=text:hello stops firing on a non-Latin layout, where that key types п. Prefix the key with key_ to bind the physical position instead — keybind = super+opt+ctrl+key_g=text:hello works on any layout. agterm's bundled defaults already use it for ⌘C, ⌘V, and ⌘A.
Programs can read and write the macOS clipboard over OSC 52. agterm prompts before a program reads your clipboard (a read hands its contents back to the program); a normal ⌘V paste is never prompted. Clipboard writes go through by default, matching other terminals so a remote tmux/vim yank still reaches your clipboard. To gate writes, set clipboard-write = ask or deny. Each prompt offers Don't ask again this session.
A ⌘-click on a file:// link — the kind ls --hyperlink, eza, and many compilers emit — reveals the file in Finder instead of opening it. A terminal renders untrusted program output, so a link could point at a .app or .command; revealing selects the file without running it, which is the security boundary — actually opening it stays a separate, explicit action. Web (http/https) and mailto links still open as before. A file:// link that names another host is ignored rather than revealed, so a stray link can't trigger a Finder network mount.
Link detection follows the program in the pane. While one has mouse reporting on — tmux with mouse on, or stock vim — ⌘-hover stops underlining and ⌘-click opens nothing, because links are detected only while reporting is off. Same in Ghostty.app, and per-program rather than per-kind of app. Hold shift as well (⌘⇧-hover, ⌘⇧-click) to reach the link anyway, unless the program claimed shift for itself. In ghostty.conf, mouse-shift-capture = never makes shift always win, and mouse-reporting = false turns reporting off for every program.
Open the file with File ▸ Edit ghostty.conf… (a 95% editor overlay, same as Edit Keymap). Apply edits made elsewhere with File ▸ Reload Config or agtermctl config reload. A malformed line is skipped and the good ones still apply; the returned diagnostic count covers every config source.
Agent status
A coding agent running in a session can flag its status on that session's sidebar row, so you can tell at a glance which of many concurrent agents needs you. The glyph shows just left of the notification badge on every non-idle session; a one-time completed flash auto-clears once you visit the session.
Every state draws a filled circle by default, so the tint is what tells them apart. The shape is configurable per state under Settings ▸ Agent Status ▸ Colors and Shapes, where each state carries its own color well and shape picker on one row. The six shapes are circle (the default), square, triangle, diamond, capsule, and star, each drawn in that state's current color. Choosing a distinct shape per state adds a second signal alongside the tint, so the states stay apart at a peripheral glance and without depending on hue.
An agent sets it over the control channel:
agtermctl session status active --target "$AGTERM_SESSION_ID"
agtermctl session status blocked --sound default --target "$AGTERM_SESSION_ID"
agtermctl session status completed --auto-reset --target "$AGTERM_SESSION_ID"
agtermctl session status blocked --color '#ff0000' --target "$AGTERM_SESSION_ID"
agtermctl session status blocked --shape triangle --target "$AGTERM_SESSION_ID"
agtermctl session status blocked --pane right --target "$AGTERM_SESSION_ID"
agtermctl session status idle --target "$AGTERM_SESSION_ID" # clear it
--auto-reset clears the indicator the moment you visit the session; --blink requests an attention pulse; macOS Reduce Motion suppresses the repeating sidebar and dashboard animation while keeping the status visible, and the pulse resumes when Reduce Motion is disabled; --sound plays a one-shot sound (default, or a system sound name like Basso/Ping/Tink). To make every blocked prompt sound without touching the hooks, set Settings ▸ Agent Status ▸ Blocked sound. --color (#rrggbb) overrides the glyph tint for that one call — it rides the status, so the next status set without it reverts to the configured color. --shape (circle, square, triangle, diamond, capsule, or star) overrides the silhouette the same way, reverting to the configured shape on the next status set without it. Both read back on tree as the session's statusColor and statusShape, each reporting the per-call override only.
--pane (left|right|scratch, defaulting to the main pane) records which pane set the status: a block set from a background pane survives typing in another pane, and when the status needs attention (blocked or completed), any GUI selection of the session (auto-follow, attention nav ⌃⌥↑/↓, plain session nav, the command palettes, a Dock-menu session, and a sidebar click) reveals and focuses that pane — the split, or a hidden scratch — instead of the main pane, so an agent that blocks or completes in a split or scratch tags its own pane to be found. An active status keeps the existing pane selection (the control session go next-attention only steps the selection, it does not itself move focus into the pane). It reads back on tree as statusPane.
Typing into a blocked/completed session clears its status; an interrupt keystroke, Esc or Ctrl-C, interrupts an active one and clears it too.
When the sidebar is hidden the glyphs go with it, so an optional title-bar bell (Settings ▸ Notifications ▸ Show attention indicator, off by default) reflects the window: dimmed when nothing needs you, plain when a session is active or completed, filled amber when any is blocked. Clicking it opens a popover of this window's non-idle sessions, sorted blocked → active → completed, that you hover to highlight and click to jump to the session. A blocked or completed row also reveals the pane that set its status; an active row keeps the existing pane selection. ⌃⇧I, Navigate ▸ Go to Attention…, or the action palette's "Show Attention" opens the same attention list as a searchable palette. Right-clicking agterm's Dock icon exposes the last-active window's same ordered list under Sessions Needing Attention, alongside its recent sessions. Over the control channel, agtermctl tree --json reports each session's status and statusPane (which pane set it).
Auto-follow blocked sessions. When several agents run at once, a session that blocks is easy to miss. Turn on Settings ▸ Agent Status ▸ Auto-follow blocked sessions (Disabled by default, or a 5s/10s/30s/60s/5m idle timeout) and, after you have been idle from input for that long, the window selects and focuses the oldest waiting blocked session, so you are pulled to whatever agent is waiting. It is per-window and window-wide (crossing workspaces within the window). Auto-follow pulls you to each blocked session at most once: after you have been shown a block and moved on (even without replying), it will not pull you back to it, so it walks the waiting blocks oldest-first, shows each once, then stays quiet. A session becomes eligible again only after it leaves blocked and re-enters it. Being parked on a blocked session likewise suppresses jumps while you stay on it. The opt-in Don't auto-follow away from a running session (off by default) holds the selection put while the current session is active. Over the control channel, agtermctl tree --json reports the window's idleMs (ms since your last input, live) and autoFollowMs (the configured timeout in ms, omitted when Disabled); agtermctl window list --json reports autoFollowMs per window (as of the last refresh), but not the live idleMs.
To wire it up automatically, Help ▸ Install Agent Status Hooks… installs a hooks package: a generic bash/zsh/fish shell integration (flags active while a command matching AGTERM_AGENT_RE runs; the default set is gemini, cursor-agent, aider, crush, goose), four Claude Code hooks (prompt → active, tool run → active, Stop → completed, permission prompt → blocked), and an OpenCode lifecycle plugin.
The scripts go to ~/.config/agterm/agent-status/, with the bundled agtermctl's absolute path baked in so they work even without the CLI on your PATH. A marker-guarded source line for the generic shell integration is added to ~/.zshrc, ~/.bashrc, and ~/.config/fish/config.fish (fish only when that directory already exists); open a new terminal for it to take effect. The per-agent hooks are merged into each agent's own config: four Claude Code hooks into ~/.claude/settings.json and six Codex lifecycle hooks into ~/.codex/config.toml. Before it changes either of those, the installer copies it beside the original with a .bak suffix, so the previous config is recoverable.
Six Codex lifecycle events run a dedicated adapter. A permission request is only a candidate because it fires before Auto Review; the adapter keeps automatic decisions active and changes the row to blocked only after a real approval or structured question appears in that pane. On Stop, a final assistant message containing ? reports blocked; every other final message reports completed and auto-resets.
Codex only: run /hooks in Codex after installing or upgrading. Codex requires changed command hooks to be reviewed and approved before they run, so the merged lifecycle hooks stay inert until you open Codex, run /hooks once, and approve them.
When Pi has already created ~/.pi/agent, the installer also adds a lifecycle extension: Pi work starts as active and settles as completed after retries and queued continuations finish. Pi has no built-in permission or structured-question event, so it does not infer blocked from assistant prose. Restart Pi or run /reload after installing it. Re-running upgrades agterm-managed integrations and is otherwise idempotent.
When OpenCode has already created ~/.config/opencode, the installer also adds a lifecycle plugin at ~/.config/opencode/plugins/agterm-status.js: session.status busy/retry → active, idle → completed (only when no session remains busy, so a task subagent cannot complete a busy parent), permission/question prompts → blocked, turn-ending session.error for a session already reported busy → blocked (following idle from halt swallowed, and a clean sibling's idle cannot erase it; abort ignored, context overflow decided by the next event — busy means compaction resumed, idle means the turn ended → blocked), reply/reject → active. Deprecated session.idle is ignored so it does not double-fire with session.status. Restart OpenCode after installing it.
Add Codex hooks by hand
The installer never rewrites a ~/.codex/config.toml it does not own. If that file already defines its own [hooks…] entries, or is not valid TOML, it is left untouched and the install alert sends you here. Add the six blocks below yourself, replacing /Users/you with your own home directory — the path has to be absolute, since Codex runs the hook without your shell's PATH.
Check the shape of your existing hooks first, because that is the reason the installer declined to do this for you. If every one of them is written as an [[hooks.…]] array-of-tables entry, appending is safe, including for events you already hook — a repeated [[…]] header adds an element rather than redefining a key, so both your hook and agterm's run. If instead you have an inline hooks = { … } table or a plain [hooks.SessionStart] table, appending makes the file stop parsing, since TOML allows neither extending a closed inline table nor overwriting a defined value. Convert those entries to the array-of-tables form first, then append. Do not add a second command line inside an existing entry — a repeated key in one table breaks the file just as surely.
The adapter script itself is already in place — the installer copies it to ~/.config/agterm/agent-status/ on every run, whether or not the config merge succeeded. Once the blocks are added, run /hooks in Codex and approve them; command hooks stay inert until you do. For the unparseable case, fixing the TOML and re-running the installer is the easier route — it will merge the blocks for you.
Restore & persistence
Sessions come back on the next launch with their directory, font size, and split state restored. Restore reconstructs the structure, not the running processes — three limitations follow from that design:
- ›Live processes are not reattached. By default a restored session re-spawns a fresh login shell in its saved directory. The optional Restore running commands on restart toggle (General settings, off by default) re-runs the command each pane had in the foreground at the last clean quit — a re-run, not a reattach. Only a single-process command restores faithfully; a force-quit or crash captures nothing, and a capture replays exactly once, since the launch that arms it clears it from the state file; and the multiplexers in restore-denylist.conf (seeded with tmux/screen/zellij) start fresh. That file is yours to edit: agterm writes a commented starter at <config dir>/restore-denylist.conf (default ~/.config/agterm/restore-denylist.conf) when none exists, one command name per line, matched on the command's basename. It sits next to keymap.conf and ghostty.conf, so changing the directory in Settings ▸ Key Mapping moves all three; with no such setting, AGTERM_STATE_DIR puts them in <state dir>/config instead. Delete a line to let that program restore, or add one to keep another from re-running; the file is read at launch, so an edit takes effect on the next one. A per-session override, agtermctl session restore, pins what a pane restores (or --none for a plain shell, --clear to drop it) — consumed on the next launch, sticky across restarts, winning over both the captured command and the session's own --command, and reading back on tree. It obeys the same setting but bypasses the denylist, since it names its command deliberately. The pinned value is shell code stored in the window's state file and readable via tree, so it must not carry secrets. A SessionStart hook can rewrite it so a non-idempotent command like claude --resume … --fork-session reattaches on restart instead of forking.
- ›The saved directory depends on OSC 7. It relies on Ghostty shell-integration (auto-injected for zsh, bash, fish, and nu). If the working directory is never reported, a session restores to the directory it was created in.
- ›Directory is saved on structural changes, not every cd. It persists on quit and on each add/close/move/rename/select — not on every prompt redraw, which would thrash the disk. A crash loses only the directory changes made since the last structural change or quit.
Troubleshooting
Where the logs and config live, how to read them, and the common problems (a keymap editor that won't open, a custom action that does nothing, missing notifications) are covered in the repo's troubleshooting guide.