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:

shell
sudo mkdir -p /usr/local/bin && sudo ln -s /Applications/ZenTerm.app/Contents/MacOS/zen /usr/local/bin/zen

A 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:

  1. The path you pass with --socket. zen uses it as given and tries nothing else.
  2. $ZEN_CONTROL_SOCK, which ZenTerm sets in each pane, when ZenTerm still answers on it.
  3. 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.

ThingAddressExample
Paneits token, the number in $ZEN_PANE31
Tabw<window>.t<tab>w1.t3
Workspaceits folder, or its title~/src/app or app
SSH hostssh: and the alias you added in Settingsssh: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 new opens a folder that has no entry in your workspaces file.
  • tab opens, shows, renames and closes tabs. zen tab new --cmd runs 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 send pastes text, so several lines arrive as one block, and --enter presses Return after it. zen pane read prints the rows on screen, or the last lines with --lines, scrollback included.
  • worktree lists, creates and removes a workspace's worktrees. zen worktree create makes 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 its keybind lines. 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:

shell
pane=$(zen tab new --cmd "npm run dev" | jq .pane)
sleep 3
zen pane read --pane "$pane" --lines 40

zen 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

CodeMeaning
0Done.
1ZenTerm 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.
2zen rejected the arguments before it sent anything.
3zen 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.