Imported from zhu-jiyuan/tmux-manager (
AGENTS.md). Install upstream withnpx skills add zhu-jiyuan/tmux-manager. Copyright stays with the author.
AGENTS.md
This file provides guidance to coding agents working in this repository.
What this is
tm 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, and a list of every such agent's pane. Typed at a prompt, tm runs the same picker in the terminal; outside tmux it attaches tmux to the pick. It is a single Rust binary (src/) plus a tmux entry script (tm.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 tm-test-<pid>-<tag>), so they never touch the real tmux server, and they drive the subcommands exactly as fzf would: TMUX, TM_STATE_DIR, TM_SNAPSHOT, FZF_QUERY, FZF_PREVIEW_COLUMNS/LINES, FZF_IDLE_TIME_MS set in the environment, assertions on the printed fzf action strings. tm_shell runs with none of those popup variables, as a person at a prompt would. The picker itself runs against FAKE_FZF, a script first on PATH that records what it was given and picks a row by id, with a control-mode client (Server::attach) standing in for a person's terminal; outside_tmux_* runs it with TMUX unset, a TMUX_TMPDIR of its own and a HOME whose .tmux.conf names the project roots, so tm has to start that server itself.
Test and verify through the tm 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. tm itself (the picker), activity, hook and install-hooks are entry points, and only the picker, activity and install-hooks show in --help; every other subcommand is hidden, is what an fzf key runs, and also runs from a plain shell inside tmux. There, without TM_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 tm mode --windows does not change a later tm list; ask for tm 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 TM_SNAPSHOT to a file in a directory you own and remove it afterwards, as the integration tests do. tmux is only tm's data source here. tm.tmux only finds (or fetches) the binary and binds keys to it, so test what those keys do through tm itself, not through the key bindings, tmux list-keys, the popup, or pty-driven key presses. Never run the picker (tm with no subcommand) with the real fzf from an agent's shell, nor tm preview-full: both open /dev/tty, which is the agent's own terminal. Put a fake fzf first on PATH instead, as the tests do. Outside tmux the picker starts the default tmux server if none runs, so point TMUX_TMPDIR (and HOME, for the config) somewhere of your own.
Running it for real: cargo build --release. tm.tmux resolves the binary once, when tmux sources it: @tm-bin, then next to the script, then target/release/tm; a checkout with neither (a TPM clone) gets one from scripts/install.sh, which tm.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 tm, which is the developer setup here. Only after that do ~/.cargo/bin/tm 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 tm.tmux itself needs the tmux config re-sourced. tm 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/tm-v<version>-<target>.tar.gz (binary + tm.tmux + README + LICENSE). Pushing a v* tag runs .github/workflows/release.yml, which does this for four targets and publishes a GitHub release. Its notes are scripts/changelog.sh <tag>: the commits since the previous tag, grouped by their Conventional Commit subjects into breaking changes, features, fixes and other changes, with the tm X.Y.Z bumps left out, so write subjects as they should read in a 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. The picker, tm [--sessions|--windows|--projects|--agents] with no subcommand (switch.rs), is the only long-lived command: it writes the rows, spawns fzf, waits, and acts on the result. It runs in whatever terminal it has. tm.tmux's keys open it in a popup and pass the hidden --client, which gets the pick and, since the popup closes with tm, any error on its status line (switch::popup). Typed at a prompt (switch::here), inside tmux it switches the client showing the pane; outside, it execs tmux attach-session on the pick. With no list named it starts on the projects, or the sessions when @tm-projects is unset. Every fzf key binding (switch::binds) runs a fresh tm <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. Every call passes -u: outside tmux, without a UTF-8 locale, tmux prints tabs and non-ASCII names as _. Outside tmux there may be no server at all; tmux::list reads that as an empty listing, so the picker shows the projects before anything runs.
Row protocol. rows/ emits one tab-separated row each: id, session name, padded tree name, padded breadcrumb name, padded dots, badge. Rows end in NUL and fzf runs with --read0, because an agent's row spans two lines (rows::items splits them back; the test's fake fzf translates NUL for awk); widths and padding count a row's widest line and pad its last. 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, rows/agents.rs. Field 1 is a RowId (rows/id.rs): $n session, @n window, %n an agent's pane, 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 tm 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 TM_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), Projects (the @tm-projects value as the picker opened). Each key is a fresh process, so a popup keeps them in files; from a shell (no TM_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 tm so they can check for a pending prompt first; otherwise they print plain accept/abort. tab (tm mode, the next list) 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 tm 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: $TM_STATE_DIR, else $XDG_STATE_HOME/tm, else ~/.local/state/tm. 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 @tm-projects tmux option (space-separated, optional :depth, ~ expanded), walked with read_dir, hidden entries and symlinks skipped, paths canonicalised. The option lives in the user's tmux config, so the picker reads it once as it opens (projects::capture: start-server ; show-option -gqv, which outside tmux starts a server that loads the config, answers, and exits with no session to keep) and keeps it in the Projects sidecar, where the keys find it; a run from a shell asks show-option itself. 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. Stars are by name, so a starred project stays starred once open. preview::directory shows path, git branch and last commit, then entries. tm.tmux binds prefix + f only when the option is set, since it shadows find-window.
Agents mode (rows/agents.rs). One row per pane whose agent is working or waiting (Data::agents), waiting first and tmux's order within a state; it skips the starred-first sort. Each row is two lines in both name columns, so it stays two lines while filtering: the dot and the session's title in bold, then, faint, harness · session:window (plus .pane in a split window) · directory · reason and age. The title is the pane's (#{pane_title}, empty while it is the host name), read by Harness::title (Claude Code's drops its status mark) and dropped when it only names the directory (Codex's); without one the directory takes the first line. The age is Record::since, which a hook keeps while the same process stays in the same state, so only hooks give one. A %n id is a valid -t target everywhere: Enter's switch-client -t %n selects the session, window and pane at once, ctrl-x is kill-pane (parking clients first when it is the session's last pane), ctrl-r renames the pane's window, ctrl-o opens a window beside it, and the preview shows the pane itself. Elsewhere the preview picks the pane whose agent most wants attention (preview::pick), so a row's preview shows what its dots announce. Over an agent's screen the preview puts its status (dot, harness, state, reason and age, then where it is) instead of the window title, and wraps long lines (ansi::fit_frame). The list takes the width its widest row needs, within 30–50% of the picker (switch::preview_window, measured by rows::widest): set as fzf starts, and again by tm mode on tab, which builds the new rows itself and shows them from the snapshot; noinfo hides the scroll indicator fzf draws over a full preview.
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, reason, 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
tm 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, reason, since, pid, process start time, harness, event) on the pane as the tmux user option@tm-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_agentfinds 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'sAgentis the harness with the strongest state (normally the only one) and itsStatus: state plus an optionalReason(permission,question,done,error, OSC 7501's words). 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 permission prompt, a question or a limit message (LIMIT, shared) means waiting for that reason, a busy hint means working, nothing means waiting for no known reason. Which events give which reason lives in each harness'shook. - Cost control:
agents()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;TM_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 (agent::by_window).
Hook installation (install.rs). tm 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 tm entries (recognised by a command containing tm 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 is the user's. tm sets no colours: the popup takes the user's popup-style, popup-border-style and popup-border-lines, and fzf the user's own theme (FZF_DEFAULT_OPTS). Rows carry their own ANSI colours and attributes, and only session headers and agents' titles are bold. 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 → agents (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.
