Imported from zhu-jiyuan/tmm (
AGENTS.md). Install upstream withnpx skills add zhu-jiyuan/tmm. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents working in this repository.
What this is
tmm is a personal tmux plugin: an fzf popup for switching, creating, renaming and closing sessions and windows, for opening project directories as sessions, with a green or yellow dot on each window where Claude Code or Codex is working or waiting. It is a single Rust binary (src/) plus a tmux entry script (tmm.tmux). README.md and README.zh.md are twins; keep both in sync when keys or options change.
Commands
Rust edition 2024 with let-chains, so the toolchain must be 1.88+. CI runs exactly these, in this order:
cargo fmt --check
cargo clippy --all-targets --locked -- -D warnings
cargo test --locked
Single tests:
cargo test --locked harness::claude::tests::screen_hints # one unit test
cargo test --locked --test integration inline_prompts_and_closing
Layout has unit tests in rows/mod.rs that build rows from hand-made panes, no tmux needed; the integration tests (tests/integration.rs) need tmux on PATH and skip silently without it. Each test starts its own server on a private socket (tmux -L tmm-test-<pid>-<tag>), so they never touch the real tmux server, and they drive the subcommands exactly as fzf would: TMUX, TMM_STATE_DIR, TMM_SNAPSHOT, FZF_QUERY, FZF_PREVIEW_COLUMNS/LINES, FZF_IDLE_TIME_MS set in the environment, assertions on the printed fzf action strings. tmm_shell runs with none of those popup variables, as a person at a prompt would.
Test and verify through the tmm CLI in the same way: build, then run the subcommand against a tmux server, the real one or a private one, and compare with the old binary's output when that helps. switch, open, activity, hook and install-hooks are entry points; every other subcommand is what an fzf key runs, and all of them also run from a plain shell inside tmux. There, without TMM_SNAPSHOT, a run is the first key of a popup of its own that lives in memory for that one process: it prints the action it would hand fzf and keeps nothing, so no file is written (favorite still stars in switcher.json) and tmm mode windows does not change a later tmm list; ask for tmm list --windows|--projects instead. fzf's variables fall back to what a shell means: an empty query, idle, the terminal's size. For a flow of several keys (prompt, then enter), set TMM_SNAPSHOT to a file in a directory you own and remove it afterwards, as the integration tests do. tmux is only tmm's data source here. tmm.tmux only finds (or fetches) the binary and binds keys to it, so test what those keys do through tmm itself, not through the key bindings, tmux list-keys, the popup, or pty-driven key presses. Never run tmm switch with the real fzf from an agent's shell, nor tmm preview-full: both open /dev/tty, which is the agent's own terminal. Put a fake fzf first on PATH instead.
Running it for real: cargo build --release. tmm.tmux resolves the binary once, when tmux sources it: @tmm-bin, then next to the script, then target/release/tmm; a checkout with neither (a TPM clone) gets one from scripts/install.sh, which tmm.tmux runs: the release tarball for the platform, verified against the sha256 digest GitHub serves for the asset, or a cargo build when there is none; it runs again when the fetched binary's --version differs from Cargo.toml (after prefix + U), and never touches a symlinked tmm, which is the developer setup here. Only after that do ~/.cargo/bin/tmm and PATH count, so a stray copy there never shadows the plugin's own. A rebuilt binary at the same path is picked up by the next popup with no reload; a change to tmm.tmux itself needs the tmux config re-sourced. tmm activity prints the per-window agent states as JSON and is the easiest way to poke at the collector from a shell inside tmux.
Release: scripts/package.sh <rust-target> builds dist/tmm-v<version>-<target>.tar.gz (binary + tmm.tmux + README + LICENSE). Pushing a v* tag runs .github/workflows/release.yml, which does this for four targets and publishes a GitHub release. The version lives only in Cargo.toml, and the installer downloads the tag v<version>, so a release tag must match it.
Architecture
One binary, one process per keystroke. tmm switch <client> (switch.rs) is the only long-lived command: it writes the rows, spawns fzf, waits, and acts on the result. Every fzf key binding (switch::binds) runs a fresh tmm <subcommand> via transform/execute-silent, and the subcommand prints an fzf action string (reload-sync(...), change-prompt(...)+change-query(...), accept, abort, ...) that fzf then applies. actions::arg() wraps values in whichever bracket pair they do not contain. Because each call is a fresh process, what matters for feel is startup cost and the number of tmux subprocesses per call (~4 ms each). tmux::panes() deliberately fetches everything the rows and the agent collector need in a single list-panes -a format string; keep it that way rather than adding second queries.
Row protocol. rows/ emits one tab-separated line per row: id, session name, padded tree name, padded breadcrumb name, padded dots, badge. rows::fetch gathers panes, agent states, favorites and (in projects mode) project directories once; rows::build turns that Data into rows without touching tmux, so layout is unit-tested (rows/mod.rs tests) with hand-made panes; rows::layout pads and joins. Each mode has its own file: rows/sessions.rs (sessions and windows), rows/projects.rs. Field 1 is a RowId (rows/id.rs): $n session, @n window, or the absolute path of a project with no session; RowId::parse returns None for the empty {1} fzf hands over when nothing matches, and every row-keyed subcommand starts with it, so the "empty id must no-op" rule lives in one place. fzf displays 3,5,6 while the query is empty and 4,5,6 while filtering (TREE_FIELDS/FILTER_FIELDS in switch.rs), searches only the first displayed field, and uses field 1 via --id-nth to keep the cursor stable across reloads. In windows mode the tree column shows a window as ├ 0 → name under a bold session header, while the breadcrumb column shows session:0 → name; the → marks the session's current window and appears only when the session has more than one window (a lone window keeps a blank slot so names stay aligned). fzf cannot match hidden text, so the change binding runs tmm with-nth to swap the columns; that swap is what keeps "type a session name, see its windows" working. Both name columns are padded to one width so the swap moves nothing else. Window rows put the session name in field 2 so ctrl-s (which takes {2}) stars the right session.
Per-popup state lives with its owner. popup.rs is the context object: Popup::create (in switch) puts the row snapshot at <state>/run/switch-<pid>.txt and exports its path as TMM_SNAPSHOT; every subcommand starts with Popup::current(). Small bits of state are sidecars of that snapshot, one file per Sidecar variant, told apart by extension: Mode (windows or projects; absent = sessions), Prompt (JSON of the open inline prompt), Preview (chosen preview window per session), View (fzf action to replay after the full-screen preview), Help (exists = legend hidden). Each key is a fresh process, so a popup keeps them in files; from a shell (no TMM_SNAPSHOT) Popup::current() is a popup held in memory (Store::Memory) that has no snapshot and goes with the process. Popup offers read/write/take/has/remove and the JSON pair on top, plus mode()/set_mode(), over either store. The files belong to the switch whose pid names them: cleanup removes every file named after the snapshot (switch-<pid>.*) on exit, so it needs no list of sidecars, and Popup::create first sweeps run/ (remove_stale), keeping only switch-<pid>.* of a live pid other than its own, so the files of a popup that was killed, sidecars without a snapshot and any other file put there go. Adding a kind of per-popup state means a new Sidecar variant, nothing else.
Inline prompts. ctrl-o/ctrl-r never leave fzf: actions::prompt swaps the prompt, prefills the query, sets a header, disables search, and saves a Pending to the Prompt sidecar: the Question (what Enter will do, settled when the prompt opens) and the user's filter. enter and esc are both routed through tmm so they can check for a pending prompt first; otherwise they print plain accept/abort. tab (mode toggle) cancels any pending prompt. restore() builds the action that puts prompt, query, header and search back. While a prompt is open, with-nth decides by the filter saved in the sidecar rather than the live query, so the columns do not flip as the user types a name.
Refresh loop. One fzf every(1) binding does refresh-preview plus a background tmm refresh. refresh only reloads when fzf reports ≥1 s idle, the rows actually differ from the snapshot, and neither favorites nor mode changed while it was computing (a key that changed them reloads on its own). The preview command must stay a short-lived process; a lingering one makes fzf draw a spinner.
Persistent state. Only starred sessions, in switcher.json under the state dir: $TMM_STATE_DIR, else $XDG_STATE_HOME/tmm, else ~/.local/state/tmm. Written atomically via paths::write_atomic (temp file + rename); use it for anything fzf might read concurrently.
Projects mode (projects.rs). The roots come from the @tmm-projects tmux option (show-option -gqv, space-separated, optional :depth, ~ expanded), walked with read_dir, hidden entries and symlinks skipped, paths canonicalised. rows/projects.rs renders a project as its session's row when the session's start directory (Data::session_dirs, from #{session_path}) is that project, otherwise the row id is the path. A session that only shares the project's name does not count, and projects::open follows the same rule, so Enter on a row always lands in the session the row showed: it finds the session by start directory or creates one, appending the parent's name when the name is taken by a session elsewhere (projects::free_name, which also names a project row with no session, so the row shows and stars the name Enter will give it); switch::choice calls it for path ids and for a typed path with no match, and tmm open <dir> exposes it. Stars are by name, so a starred project stays starred once open. preview::directory shows path, git branch and last commit, then entries. tmm.tmux binds prefix + f only when the option is set, since it shadows find-window.
Agent activity: the collector (agent.rs) and the harnesses (harness.rs, harness/<name>.rs). The collector knows tmux and ps; what a hook event or a screen means belongs to the harness that produced it.
- The seam:
harness::Harnessis everything the collector needs from one harness: recognising its process by the command linepsprints (runs), installing its hooks (install), reading a hook call (hook→Update: state, event), and naming the screen regexes (hints) thatpane_statereads when no live record settles the state;pane_statehas a default body inharness.rsthat a harness overrides only if it treats records differently. One file per harness underharness/, named inharness::ALLand nowhere else; the collector only holds&dyn Harness. A new harness is a file there and a line inALL. Anything one harness does differently (how it treats the hooks of its own subagents, say) stays in its file. - Hooks: the harnesses' lifecycle hooks run
tmm hook <harness> [event]inside the agent's pane. The collector asks the harness what the call means, walks up the process tree from its own parent to the harness's process, and stores aRecord(state, pid, process start time, harness, event) on the pane as the tmux user option@tmm-agent(agent::OPTION;tmux::panesfetches it asPane::record). pid + start time tie a record to one specific process so a reused PID cannot resurrect a stale one.main.rsswallows every hook error: a hook must never fail in front of an agent. - Reading:
agent::pane_statefinds the harness processes in the pane's tree, keeps the record if its pid and start time match one of them, and asks each harness present with its own live record (or none) plus a lazycapture-pane; the pane's state is the max over the harnesses present (normally one). A harness reads its record and, when it does not settle it (PermissionRequestfires before approval; or there is no record), its own screen regexes: a question or limit message means waiting, a busy hint means working, nothing means waiting. - Cost control:
states()skips panes sitting at their shell prompt unless a record is on them;list-panesitself answers#{==:#{pane_current_command},#{b:default-shell}}, soPane::at_promptcosts nothing. It runs oneps -tfor the ttys of the rest, andcapture-paneonly where a harness asks. Nothing guesses a harness frompane_current_command: names change under us (the native Claude Code binary is named after its version), so which harness runs, if any, is decided byrunsalone. A shell running an agent from a command line (new-window 'claude; exec zsh') looks like a pane at its prompt, so only a hook record reveals it;TMM_AGENT_SCAN=alwaysscans every pane to debug such a case. The integration test sets its server's default shell to/bin/sh, so its fake agent (a/bin/shsymlinked asclaude, which tmux reports asbashon macOS andclaudeon Linux) goes through the filter like a real one. Stateis orderedPlain < Working < Waiting; a window's state is the max over its panes.
Hook installation (install.rs). tmm install-hooks asks every harness in harness::ALL to install itself, with a command baking in the absolute path of the running binary (fzf::me()), so moving the binary means re-running it. install::merge is the tool the harnesses share: it merges command hooks into a JSON hooks file (~/.claude/settings.json, ~/.codex/hooks.json), keeping foreign entries, replacing earlier tmm entries (recognised by a command containing tmm and hook), backing the file up first, and chmod 600. A harness's event list and what each event means live together in its file.
Styling follows tmux. fzf::colours() reads #{status-style} and paints the cursor row with the status bar's bg/fg, or leaves the highlight to the user's own fzf theme (FZF_DEFAULT_OPTS) when the bar has no bg. Rows keep their own ANSI colours and attributes on the cursor row (regular without strip), so the dots stay readable there and only session headers are bold. tmm.tmux gives the popup border the same bg. A window without an agent has no dot; rows::dots paints working green and waiting yellow with the plain ANSI colours, never the bright ones, because Solarized fills the bright slots with its grey base tones. Secondary text (tree guides, breadcrumbs, badges) is SGR faint (ansi::faint) rather than a colour, since no palette index is dim under both light and dark themes. NO_COLOR is stripped from fzf's environment on purpose. ansi.rs measures visible width (skipping CSI sequences, counting CJK as two cells) for column padding and preview clipping; use visible_width/pad/clip_line rather than .len() on anything that reaches the terminal.
Conventions
- Key bindings follow fzf-lua habits and are control keys only, no arrows or function keys (HHKB). fzf's own editing keys (
ctrl-a/e/u/w,ctrl-j/k/n/p) are left alone. A new key touchesbindsandLEGENDinswitch.rs, plus the key tables in both READMEs.tabcycles sessions → windows → projects (popup::Mode::next). fzf::MIN_VERSIONlists the fzf features that pin it; bump it (and both READMEs) when a new fzf feature is used. tmux 3.3+ is required fordisplay-popupwith a border style.- fzf runs our callbacks with
SHELL=/bin/sh; quote anything passed on a command line withfzf::quote.
