# Rex keybindings for tmux + Neovim users: instructions for agents This file goes with the blog post https://brojonat.com/posts/rex-getting-started/. It's written for coding agents (and any human who wants the details). It was generated with Claude Code while setting this up on my machine, and everything in it was tested there. The goal: make Rex (https://www.superlogical.com/rex), Superlogical's terminal multiplexer, behave like a typical tmux + vim-tmux-navigator setup: - ctrl+h/j/k/l moves between Rex panes and nvim splits seamlessly - a tmux-style leader key (prefix) with tmux's default bindings under it - a keyboard "copy mode" (open the screen in nvim and yank with vim motions) - all of it guarded so the same dotfiles still work on machines without Rex Versions this was written against: | Thing | Version | | ------------------ | ------------------------------ | | Rex app | Rex Beta 0.1.0 (1203), macOS | | `rex` CLI / server | `0.0.1792158932-g88fd41` | | Neovim | 0.11.4 | | LazyVim | yes (lazy.nvim plugin manager) | Rex is in beta and moving quickly. If something here disagrees with the install in front of you, trust `rex --help`, `rex api describe ` and `curl -s https://www.superlogical.com/rex/docs/llms.txt` over this file. ## Instructions If your user pointed you here, this is a checklist for giving them the same setup, adapted to their dotfiles. Do the steps in order and run each verification before moving on. The full config is in the reference sections below. **Before you start** 1. Confirm Rex is installed and running: `rex version` and `rex client ls` should both succeed. Note the client label (e.g. `my-mac`). 2. Read the user's existing setup and adapt instead of copying blindly: - tmux prefix: `grep -n "prefix" ~/.tmux.conf`. Use their prefix as the leader, translated to Rex key syntax (`C-Space` → `ctrl+space`, `C-b` → `ctrl+b`, `C-a` → `ctrl+a`). - Custom tmux bindings (`bind-key …`): port only the ones that have a Rex action. List actions with `rex -C actions`. - Whether they use vim-tmux-navigator (check their nvim config and `.tmux.conf`). - Whether nvim uses LazyVim (`lua/config/lazy.lua` that imports `lazyvim.plugins`), plain lazy.nvim, or something else. - How they manage dotfiles (stow-style `~/dotfiles//.config/...` plus symlinks, a bare repo, chezmoi, …). Put `init.lua` where their other configs live and link it the same way. 3. Check that `~/.config/rex/init.lua` doesn't already exist. If it does, merge into it. **Rex config** 4. Write `init.lua` from the "Rex config" section below, using the user's leader. Drop the `copy_mode` action if they don't use nvim. Keep `terminal` and `leader` defined before `copy_mode`, which uses both. If ctrl+hjkl clashes with something they rely on outside vim (ctrl+l clears a shell), tell them. 5. Validate and load: `rex config check` must exit 0. Then run `rex config reload` and confirm with `rex keymap`. 6. Test the vim check without touching the user's focus. Run the matcher against every pane: ```sh rex do -e 'local t = {} for _, b in ipairs(rex.session.list_blocks{}.blocks or {}) do local p = rex.block.call("com.superlogical.terminal", "process", { block_id = b.block_id }) local fg = p and p.foreground t[#t+1] = b.block_id .. " " .. tostring(fg and fg.argv0) end return t' --json ``` **nvim config** 7. Add the guarded nvim mappings from the "nvim config" section below (`REX_SESSION` set, `TMUX` unset). - LazyVim: append to `lua/config/keymaps.lua`. **Not** a plugin spec file, because LazyVim's `h`-style maps replace them on `VeryLazy`. - Plain lazy.nvim / vanilla: anywhere that runs after every plugin that maps `` (e.g. `after/plugin/rex.lua`). - Leave the vim-tmux-navigator plugin spec alone so tmux keeps working. 8. Verify inside a **real** (not headless) nvim running in a Rex pane: `:verbose nmap ` should point at the file from step 7. If it says `Last set from …lazyvim…`, something loaded after it. 9. Test the edge handoff in a throwaway session so the user's window isn't disturbed: ```sh S=$(rex new navtest --json | jq -r .session_id) B=$(rex split -s "$S" --split right --json | jq -r '.block_ids[0]') rex send -s "$S" -b "$B" 'nvim -c vsplit' && rex send-key -s "$S" -b "$B" enter sleep 2 rex send-key -s "$S" -b "$B" ctrl+h # moves between nvim splits rex send-key -s "$S" -b "$B" ctrl+h # at the edge: focus should leave nvim rex api call session.view "{\"session_id\":\"$S\"}" # check the focused block ``` `send-key` skips Rex's keymap, so this tests the nvim half only. Ask the user to press the keys for the Rex half. **Wrap up** 10. Test `copy_mode` in the same throwaway session: `rex do -s "$S" -b "$B" ~/.config/rex/init.lua --action copy_mode`. Then check that the new block's `process` foreground is `nvim`, and that `:q` (via `rex send` + `send-key enter`) closes it. Then `rex kill "$S"`. Don't run `rex new` just to look at its output, because it always creates a session (gotcha #22). 11. Tell the user about gotchas #10 (double-tap the leader to send it), #12 (no last-pane), #13/#20 (copy mode is screen-only), #14 (nested tmux), #18 (sessions made with `rex new` may need "Refresh Sessions" in the app), and #19 (Remote Control is needed before the CLI can drive the app). 12. Leave the changes uncommitted unless they ask you to commit. ## How Rex configuration works | Fact | Detail | | ---------------------- | ------------------------------------------------------------------------------- | | Config file | `~/.config/rex/init.lua` (or `$XDG_CONFIG_HOME/rex/init.lua`, or `$REX_CONFIG`) | | Language | Lua 5.1, with Rex's API on the global `rex` table | | Bind a key | `rex.bind("cmd+e", "pane.zoom")` or `rex.bind(key, action, { args })` | | Bind a key to Lua | `rex.bind("ctrl+h", function(ctx, ev) ... return true end)` | | Key sequence (leader) | `rex.bind("ctrl+space>z", "pane.zoom")` | | Validate | `rex config check` | | Apply | `rex config reload`. **Rex doesn't watch the file.** | | See what's bound | `rex keymap` | | List available actions | `rex client ls`, then `rex -C actions` | | Docs for agents | `curl -s https://www.superlogical.com/rex/docs/llms.txt` | Shortcuts come from three layers, lowest priority first: 1. The app's built-in defaults 2. Your `init.lua` 3. Shortcuts set in the app itself ("Change Shortcut…") ## Rex config The whole `~/.config/rex/init.lua`: ```lua -- Smart pane switching with awareness of Vim splits, a la vim-tmux-navigator. -- When the focused terminal is running Vim, ctrl+hjkl falls through to it -- (nvim moves between its own splits and calls `rex focus` at the edge). -- Otherwise Rex moves focus to the neighboring pane. local terminal = "com.superlogical.terminal" -- Same set as tmux's is_vim regex: g?(view|n?vim?x?)(diff)? local function is_vim_name(name) name = (name or ""):match("[^/]*$"):gsub("^g", ""):gsub("diff$", "") return name == "view" or name:match("^n?vim?x?$") ~= nil end local function is_vim(block_id) local p = rex.block.call(terminal, "process", { block_id = block_id }) local fg = p and p.foreground if not fg then return false end return is_vim_name(fg.argv0) or is_vim_name(fg.name) end local function navigate(direction) return function(ctx) if is_vim(ctx.block_id) then return false end -- send the key to Vim rex.session.focus_direction{ direction = direction } return true end end rex.bind("ctrl+h", navigate("left")) rex.bind("ctrl+j", navigate("down")) rex.bind("ctrl+k", navigate("up")) rex.bind("ctrl+l", navigate("right")) -- Leader key sequences, mirroring the C-Space prefix in .tmux.conf and -- tmux's default bindings under it. % and " are shift+5 and shift+' on a -- US layout. local leader = "ctrl+space>" rex.bind(leader .. "shift+5", "pane.split", { direction = "right" }) rex.bind(leader .. "shift+'", "pane.split", { direction = "down" }) rex.bind(leader .. "z", "pane.zoom") rex.bind(leader .. "x", "pane.close") rex.bind(leader .. "o", "pane.focus_next") rex.bind(leader .. "c", "client.tab.new") rex.bind(leader .. "n", "client.tab.next") rex.bind(leader .. "p", "client.tab.previous") rex.bind(leader .. ",", "client.tab.rename") rex.bind(leader .. "shift+7", "client.tab.close") -- & rex.bind(leader .. "s", "session.switch") rex.bind(leader .. "shift+s", "session.new") rex.bind(leader .. "shift+4", "session.rename") -- $ rex.bind(leader .. "shift+x", "session.close") rex.bind(leader .. "r", "client.config.reload") for i = 1, 9 do rex.bind(leader .. i, "client.tab.goto", { index = i }) end -- Poor man's copy mode (tmux's prefix [): open the focused pane's screen -- read-only in nvim, zoomed to fill the window, and yank with vim motions. -- Quitting nvim closes the pane. Rex only exposes the visible screen, not -- scrollback. rex.action{ name = "copy_mode", title = "Open Screen in Neovim", run = function(ctx) local screen, err = rex.block.call(terminal, "format", { block_id = ctx.block_id, format = "text", trim = true, unwrap = true, }) if screen == nil then error(err, 0) end local path = os.tmpname() local f = assert(io.open(path, "w")) f:write(screen.content) f:close() local split, split_err = rex.session.new_split{ anchor_block_id = ctx.block_id, direction = "horizontal", side = "after", focus = true, layout = rex.layout.block{ flavor = "com.superlogical.terminal.shell", options = { -- nvim directly (not via sh) so the vim check above sees it. command = { "nvim", "-R", "-n", "+normal G", "+autocmd VimLeave * call delete('" .. path .. "')", path, }, }, }, } if split == nil then error(split_err, 0) end rex.session.zoom_block{ block_id = split.block_ids[1], zoom = true } end, } rex.bind(leader .. "[", "copy_mode") -- Press ctrl+space twice to send one to the terminal (tmux's send-prefix). rex.bind("ctrl+space>ctrl+space", "pane.send_key", { key = "ctrl+space" }) ``` What the terminal's `process` method returns, i.e. what `is_vim` matches: ```sh $ rex block call -b process '{}' {"foreground":{"pid":71864,"name":"nvim","argv0":"nvim","cwd":"/Users/me",...},...} ``` ### Leader bindings: tmux → Rex | tmux (default, after prefix) | Rex action | Rex key string (after `ctrl+space>`) | | -------------------------------- | ------------------------------------------------------ | ------------------------------------ | | `%` split left/right | `pane.split { direction = "right" }` | `shift+5` | | `"` split top/bottom | `pane.split { direction = "down" }` | `shift+'` | | `z` zoom | `pane.zoom` | `z` | | `x` kill pane | `pane.close` | `x` | | `o` next pane | `pane.focus_next` | `o` | | `;` last pane | _(none)_ | — | | `c` new window | `client.tab.new` | `c` | | `n` / `p` next/prev window | `client.tab.next` / `client.tab.previous` | `n` / `p` | | `1`–`9` select window | `client.tab.goto { index = i }` (1-based, `-1` = last) | `1`–`9` | | `,` rename window | `client.tab.rename` | `,` | | `&` kill window | `client.tab.close` | `shift+7` | | `s` choose session | `session.switch` | `s` | | `:new-session` (no default key) | `session.new` (creates and opens it) | `shift+s` | | `$` rename session | `session.rename` | `shift+4` | | `:kill-session` (no default key) | `session.close` | `shift+x` | | `[` copy mode | `copy_mode` (custom action) | `[` | | `r` (custom reload) | `client.config.reload` | `r` | | prefix prefix (send-prefix) | `pane.send_key { key = "ctrl+space" }` | `ctrl+space` | Press Escape partway through a sequence to cancel it. ### Copy mode As of this build there's no keyboard copy mode in Rex's action list. The `copy_mode` action above captures the focused pane, opens it read-only in nvim in a new pane zoomed to fill the window with the cursor on the last line, and deletes its temp file when nvim exits. The by-hand equivalent is `rex capture -b --unwrap | nvim -`. | Step | Keys | | ------ | ------------------------------------------------------ | | Enter | `ctrl+space [` | | Select | `v` / `V` / `ctrl+v`, then vim motions | | Yank | `y` (LazyVim yanks to the system clipboard by default) | | Leave | `:q` (closes the pane, back to where you were) | Limits: only the visible screen (gotcha #20). The pane is a real split zoomed over the layout, so `ctrl+space z` unzooms it into a side split. To try the action without reloading config: `rex do ~/.config/rex/init.lua --action copy_mode` (it runs against the current session for real). ## nvim config The other half of vim-aware navigation. It's only active inside Rex and outside tmux, so the same dotfiles still work with plain tmux + vim-tmux-navigator on machines without Rex: | Where you are | ctrl+h/j/k/l does | Handled by | | --------------------- | ---------------------------------------------------------- | ------------------ | | A non-vim pane | Moves focus to the Rex pane in that direction | `init.lua` | | nvim, not at the edge | Moves between nvim splits | nvim `keymaps.lua` | | nvim, at the edge | Runs `rex focus ` to move to the neighboring Rex pane | nvim `keymaps.lua` | ```lua -- LazyVim: lua/config/keymaps.lua (must load AFTER LazyVim's own keymaps) if vim.env.REX_SESSION ~= nil and vim.env.TMUX == nil then local function rex_navigate(wincmd, direction) return function() local win = vim.api.nvim_get_current_win() vim.cmd.wincmd(wincmd) if vim.api.nvim_get_current_win() == win then vim.system({ "rex", "focus", direction }) end end end vim.keymap.set("n", "", rex_navigate("h", "left"), { desc = "Go to left window" }) vim.keymap.set("n", "", rex_navigate("j", "down"), { desc = "Go to down window" }) vim.keymap.set("n", "", rex_navigate("k", "up"), { desc = "Go to up window" }) vim.keymap.set("n", "", rex_navigate("l", "right"), { desc = "Go to right window" }) end ``` ## Gotchas | # | Gotcha | What happens | Fix / workaround | | --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | 1 | Config isn't hot-reloaded | Edits to `init.lua` do nothing | `rex config reload` (or bind it: `ctrl+space>r`) | | 2 | `rex keymap` doesn't show the app's built-in shortcuts | It only lists your `init.lua` bindings | Look in the command palette; nothing in the CLI lists defaults | | 3 | App shortcuts beat `init.lua` | A config binding seems dead | Check for a shortcut set in the app on the same key | | 4 | A Lua key binding that returns `false` passes the key through to the terminal | (This is what makes vim-aware navigation possible) | Return `true` when you handled it | | 5 | vim-tmux-navigator only talks to tmux | Inside Rex, ctrl+hjkl gets stuck at nvim's outer edge | nvim-side mapping that calls `rex focus ` at the edge | | 6 | LazyVim maps `` to `h/j/k/l` on the `VeryLazy` event | Mappings set in a plugin spec file are silently replaced. `:verbose nmap ` shows `Last set from …/init.lua` | Put the mappings in `lua/config/keymaps.lua`, which LazyVim loads after its own | | 7 | Headless nvim tests (`nvim --headless`) never fire `VeryLazy` | Test passes, real nvim is broken | Test in a real nvim, or check `:verbose nmap` in the running instance | | 8 | A lazy.nvim `keys = { { "", function() … end } }` entry on a lazy-loaded plugin ran the function twice on the first press (it loads the plugin, then re-sends the key) | First press moved two panes | Don't route these through the plugin's `keys`. Plain `vim.keymap.set` | | 9 | Process `name` isn't always the program name | Claude Code showed up as `name: "2.1.301"` but `argv0: "claude"` | Check both `foreground.argv0` and `foreground.name` | | 10 | A leader key stops reaching the terminal on its own | LazyVim's `` (treesitter selection, completion) stops working | Bind `ctrl+space>ctrl+space` to `pane.send_key { key = "ctrl+space" }` | | 11 | Keys are matched without shift applied | `ctrl+space>%` doesn't parse | Write `shift+5` for `%`, `shift+'` for `"`, `shift+7` for `&`, `shift+4` for `$` (US layout) | | 12 | No "last pane" action | tmux's `prefix ;` and vim-tmux-navigator's `C-\` have no equivalent | Only directional, or next/previous in layout order | | 13 | No keyboard copy mode (as of this build) | No vi-style `v`/`y` selection like tmux copy-mode | The `copy_mode` action, which opens the screen in nvim (`ctrl+space>[`) | | 14 | tmux nested inside a Rex pane | Rex sees `tmux` in the foreground, not `nvim`, and takes ctrl+hjkl for itself | Don't nest, or add `tmux` to the pass-through check | | 15 | `rex block call` on a block in another session | `block target … not found` | Add `-s ` | | 16 | `rex send-key` skips Rex's keymap | Can't test `init.lua` bindings with it | Use it to test the _nvim_ side only. Press real keys for the Rex side | | 17 | `rex block call METHOD ARGS` | `invalid JSON in argument` | ARGS must be a JSON object, e.g. `'{}'` | | 18 | `rex new` prints only the session ID (`--json` has no label either) and doesn't switch the app to it | The new session may not show in the app's list until you run "Refresh Sessions" | `rex ls` shows the generated name. Or bind `session.new` in the app (`ctrl+space>shift+s`), which creates the session and opens it | | 19 | The app ignores actions sent from the CLI by default | `Rex.app is not accepting actions. Turn on Remote Control in its Settings under Rex Server.` | Turn on Remote Control (Settings → Rex Server) if you want the CLI or an agent to drive the app | | 20 | `rex capture` / the terminal's `format` method only return the visible screen | No scrollback | Scroll to what you want before capturing | | 21 | Starting nvim through `sh -c '…'` hides it from the vim check | `process` reports `sh` in the foreground, so Rex takes ctrl+hjkl instead of passing them to nvim | Start `nvim` directly as the block's `command` | | 22 | `rex new` (and `rex new --json`) always creates a session | Checking its output format leaves a stray session behind | Read `rex new --help` instead of trying it. Clean up with `rex kill ` | ## Other useful commands | Command | What it's for | | -------------------------------------------- | --------------------------------------------------------------------------------- | | `rex -C actions` | Every action the app supports (what you can bind) | | `rex api list` / `rex api describe ` | Server API, with JSON schemas | | `rex do -e ''` | Run Lua against the server, e.g. to test a helper before putting it in `init.lua` | | `rex skill setup` | Teach Claude Code / Codex to drive Rex | | `rex capture -b ` | Print a pane's screen | | `rex ls` | List sessions with their names (`rex new` only prints the ID) | | `rex kill ...` | Kill sessions by name or ID | | `rex session rename [SESSION] LABEL` | Rename a session |