optmux
Optimal, opinionated, batteries-included TMUX that's neat and easy for any project.
A tmuxp wrapper that creates per-project tmux config directories with TPM and plugins pre-configured.
Quick Start
# run optmux anywhere (installs on first use via uv)
uvx optmux .
# strongly recommended: install wtcode + lazygit for the full experience
brew install netj/tap/optmux
brew install netj/tap/optmux pulls in tmux, wtcode, lazygit, delta, a recent less, fzf, and gh as dependencies. See Git, diffs, and links.
New here? The Quick Start guide shows how to be an effective mentor and manager of AI coding agents like Claude Code: one worktree and window per task, with lazygit, your editor, and a shell a keystroke away in sibling panes.
Try the included example:
git clone https://github.com/netj/optmux.git && cd optmux
./example.optmux.yaml
On first run, optmux will:
- Create
.example.optmux.d/tmux/next to the YAML file - Seed a default
tmux.confwith TPM and plugins - Install TPM and all plugins (visible in window 0)
- Launch tmuxp with an isolated tmux server
Usage
With a tmuxp YAML file
Supports .optmux.yaml, .tmuxp.yaml, and .optmuxp.yaml extensions:
optmux myproject.optmux.yaml
optmux myproject.tmuxp.yaml
With a directory
optmux .
optmux /path/to/project
Opens plain tmux using .optmux.d/ in that directory (. for the current directory) — useful for a quick, isolated tmux session with the bundled config.
Without arguments
optmux
Prints usage and exits.
As a shebang
Write a tmuxp YAML config with the optmux shebang line and make it executable. This needs optmux on your PATH (e.g. brew install netj/tap/optmux):
#!/usr/bin/env optmux
session_name: myproject
windows:
- window_name: editor
panes:
- vim .
- window_name: shell
panes:
- ""
chmod +x myproject.optmux.yaml
./myproject.optmux.yaml
Stopping a session
optmux myproject.optmux.yaml stop
optmux . stop
Kills the tmux session tied to that YAML file or directory.
Warp sessions
optmux [DIR | YAML] warp [WORKDIR] [NAME]
Creates (or updates) a secondary session — named <main-session>//<workdir-name> unless you pass NAME — that links in only the windows from the main session whose panes are running in WORKDIR (defaults to the current directory). Handy when a project spans multiple git worktrees: run optmux warp from inside a worktree to get a session showing just that worktree's windows. Windows are linked, not copied, so they stay in sync with the main session.
Raw tmux access
optmux [DIR | YAML] tmux ARGS...
Runs tmux ARGS... directly against this project's isolated tmux server — no tmuxp bootstrapping, no session creation. Useful for one-off inspection or scripting without hand-rolling the -S <sock> path yourself, e.g. optmux . tmux ls or optmux . tmux kill-server.
Picking a project
optmux pick [--list] [--live] [-1] [QUERY]
Every project has its own TMUX server, so nothing shows what is running across projects. optmux remembers each project it was started in (${XDG_STATE_HOME:-~/.local/state}/optmux/projects.tsv), and optmux pick lists them with fzf (a numbered menu if fzf is missing): ● has a running session, ○ doesn't; running ones come first, most recently active on top. The preview shows the session's windows and their directories, with a [bell] on those that rang.
Enterattaches to the project's session, starting it if neededctrl-xstops the highlighted project's sessionctrl-dforgets the project (projects whose directory or YAML is gone are dropped automatically)QUERYpre-filters;-1picks without asking when exactly one project matches;--livehides projects that aren't running;--listprints TSV (state kind target session socket) for scripts
optmux pick is one action: it returns after you detach or exit (status 0), so a shell wrapper can offer to pick again. Exit status is 130 if you cancelled, and 1 if there was nothing to pick.
Running a command with optmux's settings
optmux [DIR | YAML] env COMMAND [ARGS...]
Runs the command with optmux's opinionated environment (delta as git's pager, less with the mouse and clickable links, the layered lazygit config, …). It's optmux-env as a subcommand, so it works in any shell, even where your rc has rebuilt PATH: optmux env git log -p, optmux env lazygit. Inside an optmux pane it uses that project's settings; elsewhere, give it the DIR or YAML (e.g. optmux . env git diff).
Example tmuxp YAML
Here's the included example.optmux.yaml showing shortcuts, tmux config, and window layout:
#!/usr/bin/env optmux
session_name: example
start_directory: .
optmux:
shortcuts:
C-M-b: gh browse .
C-M-e:
command: ${VISUAL:-${EDITOR:-vim}} README.md # exec directly (default for str, no latency)
new_window: true # in a new-window
E:
send_keys: ${VISUAL:-${EDITOR:-vim}} . # send_keys (command is run in a new shell)
zoom: false # do not zoom (defaults to zoom when split-window)
tmux_config:
project-settings: |
set -g status-style bg=blue
windows:
- window_name: editor
panes:
- vim .
- window_name: shell
panes:
- ""
- window_name: logs
panes:
- tail -f /var/log/system.log
Config directory
Each project gets its own .$NAME.optmux.d/ directory:
| Path | Purpose |
|---|---|
tmux/tmux.conf |
Main tmux config (editable after creation) |
tmux/tmux.*.conf |
Additional config files you can add |
tmux/tmux.sock |
Tmux server socket (isolates this project) |
tmux/plugins/ |
TPM plugin directory |
tmux/plugins-update.sh |
Run manually to update all plugins |
tmux/resurrect/ |
Saved session state (tmux-resurrect), isolated per project |
env.sh |
optmux's opinionated environment (env, git_config, lazygit), applied by optmux-env |
bin/ |
Helper scripts (optmux-*), on PATH for shortcuts (and in shells that source env.sh) |
lazygit/config.yml |
Generated from lazygit: |
env.sh, bin/, and lazygit/ are regenerated on every launch.
optmux YAML config
Add an optmux: section to your tmuxp YAML to configure shortcuts and tmux settings:
optmux:
shortcuts:
C-M-b: gh browse . # Ctrl+Alt+b (Control+Option+b on a Mac): run command directly
C-M-e:
command: ${VISUAL:-${EDITOR:-vim}} README.md # exec directly (no shell)
new_window: true # open in a new-window
E:
send_keys: ${VISUAL:-${EDITOR:-vim}} . # send_keys (runs in a new shell)
zoom: false # do not zoom (default: true for splits)
tmux_config:
project-settings: |
set -g status-style bg=blue
Shortcuts
Shortcuts bind tmux keys to commands:
C-M-*keys are bound globally (no prefix needed)- Other keys require the tmux prefix (
C-t)
What a shortcut's value can do — command:, send_keys:, new_window:,
etc. — is the action block, shared with menu: custom
items below.
Detached shortcuts
detached: true runs a command without moving your cursor — handy for things like open -R .git or gh browse . that do their real work elsewhere. It comes in three flavors:
| where it runs | when it finishes | |
|---|---|---|
detached: true |
a split pane in the current window | drops the window's zoom (see below) |
detached + float_window: true |
a floating pane over the current window | disappears, layout and zoom untouched |
detached + new_window: true |
a background window | nothing on screen changes |
remain controls what happens on exit: on-error (the default) closes silently on success and holds the pane open with a dismiss prompt on failure, false always closes, true always holds. Whenever a detached shortcut holds itself open it rings the bell, so tmux flags the window in the status line rather than leaving you to find it.
Known issue: a quick detached split loses your zoom. tmux unzooms a window whenever any pane in it dies, so a detached split pane drops the origin pane's zoom the moment the command finishes. The binding does re-zoom when it opens the split, and that holds for as long as the command runs — but nothing survives to re-apply it afterwards (a pane-exited hook fires before tmux fixes the layout, so it cannot help). Long-running commands are unaffected in practice; short ones like open -R .git unzoom you.
Workarounds, in order of preference:
float_window: true— a floating pane sits outside the layout, so opening and closing it never touches the zoom. Best fit for quick commands, and it works whatever state the window is in: because tmux 3.7b crashes if a float is created while the window is zoomed, the binding checks at press time and falls back to a background window in exactly that case, which leaves the zoom alone too. Requires tmux 3.7+; see TROUBLESHOOTING-tmux-floating-panes.md for the details.new_window: true— always safe, at the cost of the command living in a separate window you may be surprised to find later.remain: true— the pane never dies, so the zoom never drops; you dismiss it by hand. Note that each invocation leaves a pane behind, and once the window is full the split fails and the zoom is lost anyway.
Action block
shortcuts: values and menu: custom items describe an action the same
way — this is the shared vocabulary compiled by optmux/actions.py:
command:— shell command, run directly (default for a plain string value)send_keys:— sends the command to a fresh shell instead of running it directly (supports shell expansion; one line at a time)tmux:— a raw tmux command, run as-is (bypasses everything below)new_window: true— opens in a new window instead of a splitfloat_window: true— opens in a floating pane (tmux 3.7+) — see detached shortcuts abovezoom: false— disables auto-zoom on splits (default: true)detached: true— runs it without stealing focus, and holds it open on failure (seeremain)remain:—on-error(default when detached/new_window/float_window),true, orfalse— see detached shortcuts
One default differs between the two: a shortcut with none of
new_window/float_window set opens a split (it's a keybinding, so
you're usually mid-task in the current window); a menu custom item with
neither set opens a new window (a menu click is more often "go start
this over there"). Set either explicitly to override.
plugins
plugins: maps each TPM plugin to its options: a dict of option → value (rendered as set -g @option "value"; @ optional, $VAR expands, YAML on/off booleans are written as on/off), or a list of raw tmux config lines. Leave it empty for none. optmux installs them on launch and writes optmux-plugins.conf, sourced before TPM runs:
optmux:
plugins:
laktak/extrakto:
extrakto_grab_area: window recent # same as: set -g @extrakto_grab_area "window recent"
extrakto_filter_order: word all line
tmux-plugins/tmux-resurrect:
- set -g @resurrect-dir "$OPTMUX_DIR/tmux/resurrect" # or raw tmux lines
Morantron/tmux-fingers: false # disable a bundled plugin
A dict entry may also carry _tip_url: (a string or a list) to showcase a great page or video; keys starting with _ are metadata, not options. The tips screen lists every plugin with its GitHub URL (derived from user/repo) plus any _tip_url links beneath it:
optmux:
plugins:
laktak/extrakto:
_tip_url: https://www.youtube.com/watch?v=VIDEO_ID
extrakto_key: X
Layers merge by plugin (bundled, then project, then ~/.optmux.yaml); a later entry replaces that plugin's lines whole (including _tip_url). See optmux/data/optmux-defaults.yaml for the bundled list.
tmux_config
Entries under tmux_config: are written as tmux.optmux-extras.{name}.conf files and auto-sourced by tmux.
Menu
tmux has no API for editing its built-in right-click menus (session, window,
pane) — the only way to change one is to redefine it whole. menu: lets you
do that from YAML: list the items you want, in order, and optmux compiles a
static bind/display-menu config for you. A bare "*" pulls in the rest
of tmux's own default items for that menu, unchanged, so you only spell out
what you're adding or reordering:
optmux:
menu:
pane:
- key: w
title: New Window (C-t c)
new_window: true # bundled default: adds "New Window" ahead of tmux's own items
- "*"
key:/title:— the accelerator and label for a custom item, plus the action block fields (command:,tmux:,send_keys:,new_window:,float_window:,zoom:,detached:,remain:)default: <name>(or a bare string) — reuses one of tmux's own items by name; addtitle:/key:alongside it to retitle or remap that item without changing what it doesseparator(or"") — a divider line- Supported contexts:
session,window,pane. A context left out ofmenu:keeps tmux's untouched default.
Reordering/retitling only touches items you name. "*" inserts unclaimed
defaults (in tmux's own order) wherever it appears — it doesn't replace a
default item you've already referenced by name, and it doesn't drop one just
because you added a same-key custom item ahead of it. To actually replace a
default item's behavior (not just its label or key), leave "*" out and
list every item you want, in order — see the session example below.
More examples:
optmux:
menu:
# Pane menu: a floating lazygit, a "run tests" that types into a split,
# and a background clipboard copy -- then everything tmux ships by default.
pane:
- key: g
title: lazygit
command: lazygit
float_window: true # floats over the window; layout/zoom untouched
- key: t
title: Run tests
send_keys: pytest -x # types the command into a fresh split and runs it
- key: y
title: Copy path
command: printf '%s' "$PWD" | pbcopy
detached: true # menu items default to new_window, so this is a
# background window -- nothing on screen changes
- "*"
# Window menu: keep everything, just rename/remap Kill to something less
# trigger-happy under the mouse.
window:
- default: Kill
title: Close Window
key: q
- "*"
# Session menu: replace Detach with a confirm-before version, and add
# tmux's own window/pane picker (normally only reachable via prefix w).
# No "*" here -- confirm-before *replaces* the item at 'd', so the rest
# is spelled out in tmux's own order to keep Detach's original binding
# from also showing up. 'T' avoids colliding with New Window's 'w'.
session:
- key: d
title: Detach (confirm)
tmux: confirm-before -p "detach? (y/n)" detach-client
- key: T
title: Windows/Panes
tmux: choose-tree -Zw
- Next
- Previous
- separator
- Renumber
- Rename
- separator
- New Session
- New Window
A context left out of menu: entirely keeps tmux's untouched default. Supported contexts: session, window, pane.
env
Environment variables for commands run through optmux-env. $VAR expands at launch, and a null value unsets the variable. Layers merge by variable name.
optmux:
env:
EDITOR: nvim
LESS: -R --mouse # replaces the bundled -XRiMWSc --mouse
PAGER: # null: unset
optmux-env
env, git_config, and lazygit are all written to $OPTMUX_DIR/env.sh. They apply only to commands started through optmux-env, and to everything those commands start in turn. Your shells inside optmux behave exactly as they do outside it.
The bundled shortcuts use it, by absolute path ("$OPTMUX_DIR/bin/optmux-env"), since your shell startup files may rebuild PATH: C-M-g runs lazygit and C-M-d runs the PR diff through it. Inside those, optmux's settings override yours: lazygit runs git, git runs delta as its pager, delta runs less, and less runs the link handler, all with optmux's settings. Use it in your own shortcuts the same way:
optmux:
shortcuts:
C-M-l:
command: |
"$OPTMUX_DIR/bin/optmux-env" git log -p
Your shell rc may rebuild PATH and drop optmux-env; optmux env COMMAND... always works. To use optmux's settings in every shell inside optmux, add this to your shell rc. It also puts the optmux-* helpers back on PATH if your rc reorders it:
[ -n "$OPTMUX_DIR" ] && . "$OPTMUX_DIR/env.sh"
git_config
git config for commands run through optmux-env, passed through GIT_CONFIG_PARAMETERS (the env equivalent of git -c, which, unlike GIT_CONFIG_COUNT, delta also reads for its delta.* settings). It takes precedence over ~/.gitconfig and repo config without modifying either. Keys merge across layers. A pager setting (core.pager, interactive.diffFilter, pager.*) whose command isn't installed is skipped, so a missing delta falls back to your own pager.
optmux:
git_config:
delta.side-by-side: true
delta.line-numbers: false
core.pager: # null: drop the bundled delta pager
lazygit
lazygit config, written to $OPTMUX_DIR/lazygit/config.yml and loaded after your own lazygit config via LG_CONFIG_FILE. Settings optmux doesn't mention keep your values, and the ones it does mention use optmux's. Layers deep-merge (lists replace). A git.diffRenderers entry whose command isn't installed is skipped.
optmux:
lazygit:
gui:
nerdFontsVersion: "3"
git:
diffRenderers:
- colorArg: always
command: delta --paging=never --side-by-side
Personal config (~/.optmux.yaml)
Create ~/.optmux.yaml to define personal defaults that apply to all optmux sessions:
optmux:
shortcuts:
C-M-g: lazygit
tmux_config:
my-defaults: |
set -g status-style bg=green
Personal config is merged with per-project config. When both define the same key, personal settings take precedence.
Customization
- Edit
tmux/tmux.confto change tmux settings - Drop
tmux/tmux.mysetup.conffiles for additional config (auto-sourced) - Run
tmux/plugins-update.shfrom inside tmux to update plugins - Press
prefix + Rto reload the config
Environment variables
optmux sets these before launching tmux/tmuxp:
| Variable | Value |
|---|---|
OPTMUX_DIR |
Absolute path to the .$NAME.optmux.d/ directory |
OPTMUX_NAME |
Name derived from YAML filename or cwd (e.g., myproject) |
TMUX_PLUGIN_MANAGER_PATH |
$OPTMUX_DIR/tmux/plugins |
PATH |
$OPTMUX_DIR/bin prepended (optmux-* helper scripts) |
optmux-env additionally sets, for the command it runs:
| Variable | Value |
|---|---|
anything under env: |
e.g. PAGER, LESS, LESS_OSC8_OPEN_ANY by default |
GIT_CONFIG_PARAMETERS |
from git_config: |
LG_CONFIG_FILE |
your own lazygit config, then optmux's from lazygit: |
Git, diffs, and links
optmux's shortcuts open git review tools with an opinionated setup. It's configured in optmux-defaults.yaml under env, git_config, and lazygit, and applied only to what the shortcuts start, through optmux-env. Your dotfiles and your shells are left alone. See the Quick Start guide for how it fits into working with coding agents.
- lazygit (
C-M-g): optmux's config is layered on top of yours. It addsCtrl-gto diff against the default branch (origin/HEAD). Diffs render with delta, and clicking a line number opens it in your editor. - Clickable diff (
C-M-d, same asCtrl-gin lazygit): what the branch changes against the default branch, rendered by delta in less, so you can scroll with the mouse and click a line number twice to edit it. - delta: becomes git's pager (
core.pager,interactive.diffFilter), with line numbers,n/Nfile navigation, moved-code coloring, and hyperlinked line numbers and commit hashes. - less: mouse mode (
--mouse) and an OSC 8 hyperlink handler. Click a link once to select it and again to open it:file://…#L<n>opens$VISUAL/$EDITORat that line, andhttp(s)links open your browser. Clicking links needs a recentless(Homebrew's; the formula depends on it).
Bundled helper scripts in $OPTMUX_DIR/bin, available in every pane:
| Script | What it does |
|---|---|
optmux-open-url URI |
Opens a hyperlink: files in your editor at the linked line, web links in the browser |
optmux-env CMD... |
Runs a command with optmux's settings. See optmux-env |
optmux-diff-base |
Diffs the branch against the default branch (origin/HEAD, else origin/main): a diffstat, then the patch, i.e. what a PR would contain (C-M-d, lazygit's Ctrl-g) |
optmux-gh-open |
Opens the current branch's PR on GitHub, or the repo page (C-M-w) |
Session persistence
optmux ships tmux-resurrect:
C-t C-s— save the current session (windows, panes, working directories)C-t C-r— restore the last saved session
Because each project has its own .$NAME.optmux.d/tmux/resurrect/ folder (see Config directory), saves never mix between projects. Save project A, then project B — restoring either one brings back only its own layout.
Clipboard integration
optmux ships tmux-yank and sets set-clipboard on + allow-passthrough on, so yanking in copy-mode lands on your system clipboard. Locally that goes through pbcopy/xclip. Over SSH, OSC 52 is the only mechanism — the remote tmux emits an escape sequence and your local terminal writes it to the clipboard, with no relay, agent, or daemon in between.
macOS Terminal.app has no OSC 52 support
Local copying still works, but copying from a remote optmux session over SSH will not reach your pasteboard — Terminal.app discards the escape sequence, and no tmux configuration can change that. optmux deliberately doesn't ship a clipboard relay to work around it; that means a socket on your laptop accepting arbitrary bytes into your pasteboard, to replace what modern terminals already do natively.
Workaround: turn off View → Allow Mouse Reporting (⌘R), or hold Fn, to hand the mouse back to Terminal and drag-select with its own selection, then ⌘C. ⌘R again returns the mouse to tmux. You get only what's on screen, and the selection cuts across split panes — zoom first with prefix + z.
Better: use a terminal that supports OSC 52 — Ghostty, iTerm2, Warp, kitty, WezTerm, or Alacritty. The tips screen (window 0, or C-M-h) nags about this unless it can confirm your terminal, using the attached client's TERM_PROGRAM and terminal type — over SSH only the latter survives, so xterm-ghostty and friends are still recognized on remote hosts.
Development
# install the latest main branch
uvx git+https://github.com/netj/optmux.git
# local editable install for development
uv tool install -e .
# test any local changes directly (best for testing branches)
uv run optmux ./example.optmux.yaml
# run tests
uv run pytest # all tests
uv run pytest -m "not e2e" # skip E2E tests (no tmux needed)
uv run pytest -m e2e # E2E only (requires tmux)
License
Metadata
Release files for optmux 0.13.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| optmux-0.13.1.tar.gz | 51.2 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| optmux-0.13.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.7 kB
Release files / optmux-0.13.1.tar.gz
| Download URL | optmux-0.13.1.tar.gz |
|---|---|
| Size | 51.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e9c4038e44453afadb926b3949330269644c1f83bb92900a1bb110b8f752e28c
|
|
BLAKE2b-256 checksum How to use checksums |
61149eb2f79cd767ab4245103a4e00503409413b6883714cb0902a22fba455a7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / optmux-0.13.1-py3-none-any.whl
| Download URL | optmux-0.13.1-py3-none-any.whl |
|---|---|
| Size | 47.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
32e55a05e1e00d627c45cf5480a0ce9528eb8c605b5af4e7726c2e8be8f181e5
|
|
BLAKE2b-256 checksum How to use checksums |
6f78f3cf076c5e97a347e573e504f0e9784214215b97d76f2b1fac63b1eaa637
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.5 {"installer":{"name":"uv","version":"0.13.5","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Security for optmux 0.13.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.