zen CLI
zen drives a running ZenTerm from the command line. It opens workspaces, tabs
and panes, types into a pane and reads its screen, and makes and removes
worktrees. A script or an agent in one pane can start a dev server in another tab
and read what it prints.
Install
ZenTerm ships zen inside the app, at ZenTerm.app/Contents/MacOS/zen, and adds
that folder to the end of the PATH in every pane on this Mac. zen runs in any
ZenTerm pane with nothing to install.
To run it from another terminal or an editor, link it into /usr/local/bin:
sudo mkdir -p /usr/local/bin && sudo ln -s /Applications/ZenTerm.app/Contents/MacOS/zen /usr/local/bin/zenA new Apple Silicon Mac has no /usr/local/bin, so the command makes it first. If
ZenTerm lives somewhere other than /Applications, point the link at that copy.
Cron and launchd run with a short PATH, so call zen by its full path there.
The PATH entry comes last, so a different zen earlier on your PATH runs
instead. Call ZenTerm's by its full path, or through the link, when that happens.
zen --version prints the version of the ZenTerm it ships in, through the link
too.
How zen finds ZenTerm
ZenTerm listens on a control socket while it runs, one for each running copy.
zen picks the socket in this order:
- The path you pass with
--socket. zen uses it as given and tries nothing else. $ZEN_CONTROL_SOCK, which ZenTerm sets in each pane, when ZenTerm still answers on it.- The one ZenTerm that is running. When more than one runs, zen lists their
sockets and exits, and you pick one with
--socket.
In a pane, zen also sends $ZEN_PANE, the pane's token, so ZenTerm knows which
pane called. It sends the token only to the ZenTerm that set it.
A pane on an SSH host can't run zen: the remote shell has no
$ZEN_CONTROL_SOCK and no zen on its PATH. Run it from a local pane. A
tool float has zen on its PATH but no $ZEN_CONTROL_SOCK or $ZEN_PANE,
so zen in a float acts on the key window.
Name a pane, tab or workspace
zen names things the way zen list prints them.
| Thing | Address | Example |
|---|---|---|
| Pane | its token, the number in $ZEN_PANE | 31 |
| Tab | w<window>.t<tab> | w1.t3 |
| Workspace | its folder, or its title | ~/src/app or app |
| SSH host | ssh: and the alias you added in Settings | ssh:devbox |
A workspace argument that starts with /, ~ or . is a folder, and zen reads a
relative one against its own folder. Anything else is a title. A host takes its
alias, not its name.
With no address, a command acts on the pane zen runs in, or on that pane's tab or workspace. Outside a pane, it acts on the key window's focused pane, active tab and workspace. When ZenTerm is in the background, it acts on the frontmost ZenTerm window.
zen list prints every window, workspace, tab, pane and drawer as JSON, and
zen list --pretty prints them as a tree:
w1 key
app /Users/me/src/app active configured
w1.t3 app active
31 nvim /Users/me/src/app busy
32 bottom drawer /Users/me/src/app
w1.t4 npm run dev
33 npm run dev /Users/me/src/app busy
Open in the background
zen leaves the screen as it is. A new tab opens behind the one showing, a new workspace joins the sidebar, and a new pane leaves focus where it was. A card, a confirm, a tool float or scroll mode stays open.
Pass --focus to switch to what zen opened, as a click would. ZenTerm brings its
window forward when another window is in front. zen workspace switch,
zen tab select and zen pane focus change the screen with no flag.
What zen does
Each command is zen <noun> <verb>. Run zen --help for the nouns, and
zen <noun> --help for its verbs and their flags.
- workspace opens a workspace by folder or title, switches to it and closes
it. Opening one that is already open finds it in whichever window holds it.
zen workspace newopens a folder that has no entry in your workspaces file. - tab opens, shows, renames and closes tabs.
zen tab new --cmdruns a command in the new tab, and zen prints the tab's address and its pane's token. - pane splits, focuses, closes, types into and reads panes.
zen pane sendpastes text, so several lines arrive as one block, and--enterpresses Return after it.zen pane readprints the rows on screen, or the last lines with--lines, scrollback included. - worktree lists, creates and removes a workspace's
worktrees.
zen worktree createmakes the branch, copies what the workspace carries, and opens the worktree as a workspace. - action runs an action by its config name, as its shortcut would:
zen action toggle_sidebar. The sample in the configuration reference names the actions on itskeybindlines. A name zen doesn't know gets an error that lists every action.
A command that returns something prints it as JSON, except zen pane read, which
prints the text. The rest print nothing.
Start a server and read its output
A script or an agent in a ZenTerm pane can start work in another tab and check on it:
pane=$(zen tab new --cmd "npm run dev" | jq .pane)
sleep 3
zen pane read --pane "$pane" --lines 40zen tab new prints the new tab and its pane:
{
"pane" : 33,
"tab" : "w1.t4"
}
The tab opens behind the one showing, in the folder of the pane that ran zen. jq comes with macOS 15 and later, and Homebrew has it for earlier versions.
Until a pane's shell starts, zen pane send and zen pane read fail with
Pane 33 has not started.
Close running work
ZenTerm refuses a close that would stop running work, and zen prints what would stop:
$ zen tab close w1.t4
zen: Closing tab w1.t4 would stop npm run dev. (refused)
33 npm run dev /Users/me/src/app
Pass --force to go ahead.
ZenTerm also refuses to close a window's last tab or last workspace, and a tab
that holds a host's login while the host connects. It refuses to remove a worktree
when the worktree's tabs run something, when it holds uncommitted or untracked
files, or when it holds commits no branch has, and zen lists those files first.
--force goes ahead in each case. ZenTerm refuses to remove a locked worktree,
with or without --force.
Exit codes
| Code | Meaning |
|---|---|
0 | Done. |
1 | ZenTerm answered with an error, or didn't answer within 10 seconds (5 minutes for zen worktree create and zen worktree remove). Also when ZenTerm speaks a newer protocol than this zen. |
2 | zen rejected the arguments before it sent anything. |
3 | zen couldn't reach ZenTerm: it isn't running, more than one is running and you passed no --socket, or the connection failed or closed without an answer. |
The protocol
zen sends newline-delimited JSON over the control socket. The control protocol documents every command, its arguments and its replies, for when you'd rather talk to ZenTerm from your own code.