Send code, prompts, and commands from Neovim to programs in tmux. Use Wiremux with AI assistants, terminals, or other tools.
demo.mp4
A target is a tmux pane or window that receives your text. Wiremux connects your editor to that target.
| Term | Meaning |
|---|---|
| Definition | Configuration that tells Wiremux how to create a target. |
| Instance | A tmux pane or window that already exists. One definition can create several instances. |
| Draft | A message that you edit in Compose before you send it. A draft can contain several pages. |
Definitions live in your Neovim configuration. Target metadata lives in tmux. Target panes remain in tmux after you restart Neovim.
Use Neovim 0.11 or later inside tmux. We recommend tmux 3.0 or later.
Add this lazy.nvim specification:
{
"MuhametSmaili/wiremux.nvim",
cmd = "Wiremux",
opts = {
targets = {
definitions = {
terminal = { kind = "pane", split = "horizontal" },
},
},
},
}- Run
:Wiremux create. - Select
terminalto create its pane. - Return to the Neovim pane with your tmux navigation keys.
- Run
:Wiremux send ls -la.
Wiremux pastes ls -la into the terminal and moves focus there.
Press Enter in that pane to run the command.
Wiremux does not need an extra picker plugin. It can use fzf-lua, Snacks, or vim.ui.select.
Other plugin managers
Install the plugin with your manager:
-- packer.nvim
use { "MuhametSmaili/wiremux.nvim" }" vim-plug
Plug 'MuhametSmaili/wiremux.nvim'After the plugin loads, configure the terminal definition:
require("wiremux").setup({
targets = {
definitions = {
terminal = { kind = "pane", split = "horizontal" },
},
},
})Default configuration
These are the shipped defaults. Copy only the options you want to change into your existing configuration. Wiremux has no target definitions or editor-wide keymaps by default.
require("wiremux").setup({
log_level = "warn",
targets = {
definitions = {},
},
actions = {
close = { behavior = "pick" },
create = { behavior = "pick", focus = true },
send = { behavior = "pick", focus = true, compose = false },
focus = { behavior = "last", focus = true },
toggle = { behavior = "last", focus = false },
},
context = {
resolvers = {},
},
ui = {
compose = {
width = 0.6,
height = 0.4,
title = " Compose Message ",
border = "rounded",
style = "minimal",
footer = true,
close_behavior = "ask",
on_new_payload = "ask",
history_limit = 0,
use_default_keymaps = true,
wo = {
wrap = true,
number = false,
relativenumber = false,
},
keymaps = {
send = { { "<CR>", mode = { "n" }, desc = "Send to target" } },
close = {
{ "q", mode = "n", desc = "Close draft" },
{ "<Esc>", mode = "n", desc = "Close draft" },
},
append_next = { "<C-s>", mode = "n", desc = "Hide + append next" },
discard = {},
files = { { "<C-f>", mode = { "n", "i" }, desc = "Insert file" } },
delete_page = { "<C-x>", mode = "n", desc = "Delete compose page" },
preview_placeholder = { "K", mode = "n", desc = "Preview placeholder" },
previous = { "<C-p>", mode = "n", desc = "Previous compose page" },
next = { "<C-n>", mode = "n", desc = "Next compose page" },
},
},
},
picker = {
adapter = nil, -- Detect fzf-lua or Snacks, then fall back to vim.ui.select.
instances = {
filter = function(inst, state)
if not inst.managed or inst.orphaned then
return inst.session_id == state.session_id
end
return inst.origin == state.origin_pane_id
end,
sort = function(a, b)
return (a.last_used_at or 0) > (b.last_used_at or 0)
end,
},
targets = {
filter = nil,
sort = nil,
},
},
})Real-life configuration: AI tools, keymaps, and prompts
Use this lazy.nvim specification instead of the minimal example. Change the tools, keys, and prompts to match your workflow. Install the AI tools you want to use. Remove definitions for tools you do not use.
Lowercase message keys add to a draft. Uppercase message keys send directly to the last target. This example enables Compose by default and saves the last three confirmed messages.
local review_changes = "Review the changes in {file} for bugs and regressions."
.. "\nReport findings with file/line references."
.. "\nDo not modify files."
.. "\n\n{changes}"
local prompts = {
{ label = "Review file changes", value = review_changes },
{ label = "Fix file diagnostics", value = "Fix the diagnostics in {file}:\n{diagnostics_all}" },
{ label = "Fix line diagnostics", value = "Fix this diagnostic:\n{diagnostics}" },
{ label = "Add docs", value = "Add documentation to {this}" },
{ label = "Explain", value = "Explain {this}" },
{ label = "Fix", value = "Fix {this}" },
{ label = "Optimize", value = "How can {this} be optimized?" },
{ label = "Review file", value = "Review {file} for issues" },
{ label = "Write tests", value = "Write tests for {this}" },
{ label = "Fix quickfix entries", value = "Help me fix these issues:\n{quickfix}" },
}
return {
"MuhametSmaili/wiremux.nvim",
cmd = "Wiremux",
opts = {
targets = {
definitions = {
pi = { cmd = "pi", kind = { "pane", "window" }, split = "horizontal", shell = false },
opencode = { cmd = "opencode", kind = { "pane", "window" }, split = "horizontal", shell = false },
claudecode = { cmd = "claude", kind = { "pane", "window" }, split = "horizontal", shell = false },
kiro = { cmd = "kiro-cli", kind = { "pane", "window" }, split = "horizontal", shell = false },
},
},
actions = {
send = { compose = true, behavior = "pick", focus = true, submit = false },
},
ui = {
compose = {
close_behavior = "hide",
on_new_payload = "append",
history_limit = 3,
},
},
},
keys = {
{
"<leader>aa",
function() require("wiremux").send("{this}") end,
mode = { "n", "x" },
desc = "Add context to draft",
},
{
"<leader>aA",
function() require("wiremux").send("{this}", { compose = false, behavior = "last", focus = true }) end,
mode = { "n", "x" },
desc = "Send context directly",
},
{
"<leader>ae",
function() require("wiremux").send() end,
desc = "Open or resume draft",
},
{
"<leader>ar",
function() require("wiremux").send(review_changes) end,
desc = "Compose review of file changes",
},
{
"<leader>aR",
function() require("wiremux").send(review_changes, { compose = false, behavior = "last", focus = true }) end,
desc = "Send review of file changes directly",
},
{
"<leader>af",
function() require("wiremux").send("{file}") end,
desc = "Add file reference to draft",
},
{
"<leader>aF",
function() require("wiremux").send("{file}", { compose = false, behavior = "last", focus = true }) end,
desc = "Send file reference directly",
},
{
"<leader>av",
function() require("wiremux").send("{selection}") end,
mode = "x",
desc = "Add selection to draft",
},
{
"<leader>aV",
function() require("wiremux").send("{selection}", { compose = false, behavior = "last", focus = true }) end,
mode = "x",
desc = "Send selection directly",
},
{
"<leader>ad",
function() require("wiremux").send("{diagnostics}") end,
mode = { "n", "x" },
desc = "Compose line diagnostics",
},
{
"<leader>aD",
function() require("wiremux").send("{diagnostics}", { compose = false, behavior = "last", focus = true }) end,
mode = { "n", "x" },
desc = "Send line diagnostics directly",
},
{
"<leader>ag",
function() require("wiremux").send("{diagnostics_all}") end,
mode = { "n", "x" },
desc = "Compose file diagnostics",
},
{
"<leader>aG",
function() require("wiremux").send("{diagnostics_all}", { compose = false, behavior = "last", focus = true }) end,
mode = { "n", "x" },
desc = "Send file diagnostics directly",
},
{
"<leader>ap",
function() require("wiremux").send(prompts) end,
mode = { "n", "x" },
desc = "Choose prompt for draft",
},
{
"<leader>aP",
function() require("wiremux").send(prompts, { compose = false, behavior = "last", focus = true }) end,
mode = { "n", "x" },
desc = "Choose prompt to send directly",
},
{
"<leader>ah",
function() require("wiremux").history() end,
desc = "Recover a Compose message",
},
{
"<leader>ac",
function() require("wiremux").create() end,
desc = "Create AI target",
},
{
"<leader>ao",
function() require("wiremux").focus({ behavior = "pick" }) end,
desc = "Focus AI target",
},
{
"<leader>ax",
function() require("wiremux").close() end,
desc = "Close AI target",
},
{
"ga",
function() return require("wiremux").send_motion({ compose = false }) end,
mode = { "n", "x" },
expr = true,
desc = "Send motion or selection directly",
},
},
}Press <leader>ac to create an AI target. Select a tool, then choose a pane or window.
Use <leader>aa to collect context from several locations.
Use <leader>ae to resume the draft without adding a page.
Use <leader>ar to compose a review request for the current file's changes.
The prompt asks for findings without file edits. <leader>aR sends that request directly.
Use <leader>ap to choose a prompt for the draft. <leader>aP sends the selected prompt directly.
Use <leader>ah to recover a saved message.
File references contain paths, not file contents. Messages do not press Enter in the target. When you are ready to submit the message, press Enter in the target.
These mappings load Wiremux on first use. No extra picker plugin is required. History remains off by default outside this example.
Use send() in Lua or in a keymap:
require("wiremux").send("Explain {this}"){this} adds the source location and any visual selection.
Select code first to include it in the message.
Send a selection, run a command, or choose a prompt
Send only the selected text:
require("wiremux").send("{selection}", { focus = true })Use submit = true to press Enter after the text:
require("wiremux").send("ls -la", { submit = true })Pass a list to choose a prompt:
require("wiremux").send({
{ label = "Explain this", value = "Explain {this}" },
{ label = "Review changes", value = "Review my changes:\n{changes}" },
{ label = "Run tests", value = "npm test", submit = true },
})Each item needs value. Optional fields include label, visible, compose, and placeholders.
See :help wiremux.send() for all item fields.
For keystrokes around the text, use pre_keys and post_keys:
require("wiremux").send({
value = "my text",
pre_keys = { "C-c" },
post_keys = { "Escape" },
})Compose lets you edit a message before you select a target:
require("wiremux").send("Review {this}", { compose = true })In normal mode, press Enter to send the draft. In insert mode, Enter adds a line.
Unsent drafts last only for the current Neovim session.
Code review with codediff.nvim
How-to-review-code-with-codediff-and-wiremux-showcase.mp4
The video shows how to use Wiremux with codediff.nvim to quickly review code changes, collect notes in Compose, and send them to your preferred AI agent. This example uses Pi.
Set up the cdf command used in the video
The cdf command in the video is this Zsh function. Add it to your ~/.zshrc:
cdf() {
command nvim -c 'CodeDiff --exit-on-close' "$@"
}With codediff.nvim installed, run cdf from your repository to open CodeDiff.
The --exit-on-close flag exits Neovim when you close the diff view.
Default keys and page behavior
| Key | Mode | Action |
|---|---|---|
| Enter | Normal | Send all pages. |
q / Escape |
Normal | Close the draft according to its policy. |
Ctrl+s |
Normal | Hide the draft and append the next Compose input. |
Ctrl+p / Ctrl+n |
Normal | Select the previous / next page. |
Ctrl+x |
Normal | Delete the current page. |
K |
Normal | Preview the placeholder under the cursor. |
Ctrl+f |
Normal or insert | Insert a file reference. |
If one page remains, Ctrl+x clears it without closing the draft.
Discard has no default key. Use require("wiremux").compose.discard() or assign a custom mapping.
Ctrl+s affects only the next non-empty Compose input.
Reopening the draft without new text does not consume it.
It does not send a message or save history.
Press K again to enter the preview. Press q or Escape there to close it.
The close_behavior option controls q and Escape.
Its default is "ask". The other choices are "hide" and "discard".
Collect notes from several locations
Use this mapping in normal or visual mode:
vim.keymap.set({ "n", "x" }, "<leader>ar", function()
require("wiremux").send("Review this context:\n{this}", {
compose = {
title = " Review Context ",
close_behavior = "hide",
on_new_payload = "append",
},
})
end)- Select code at the first location.
- Press
<leader>ar. - Press
oto add a line and enter insert mode. - Type your comment.
- Press Escape to return to normal mode.
- Press
qto hide the draft. - Select code at another location.
- Press
<leader>aragain to add a page.
In normal mode, use Ctrl+p and Ctrl+n to review the pages.
Wiremux sends them together, with a blank line between pages.
Each page remembers its source location, selected text, and source line. Other values resolve when you confirm the draft.
Call require("wiremux").send() without text to reopen the draft.
This call does not add a page.
By default, new text opens a choice dialog for an existing draft.
on_new_payload = "append" removes that extra choice.
History keeps recent messages for recovery and reuse. It is off by default.
Save and reuse messages with history
After you enable history:
- If a pane closes or delivery fails, you can recover the message.
- You can review the text of a previous message.
- You can send the same message to another pane.
To keep the last three confirmed Compose messages, add this to your existing options:
ui = {
compose = {
history_limit = 3,
},
},- Run
:Wiremux history. - Select a message to reopen it in Compose.
- In normal mode, press Enter to send it.
- If a target picker opens, choose a pane.
For a Lua keymap, use require("wiremux").history() to open the same picker.
You can edit the message before you send it. History reopens the final text as one page. Placeholders do not resolve again.
Only confirmed Compose messages enter history. History stores their final text before delivery.
Files live in stdpath("state")/wiremux/history/.
A lower limit removes older entries on the next history access.
A limit of 0 removes all saved entries on that access.
See :help wiremux-compose-history for storage details.
A placeholder adds editor context to your message. These are all the built-in placeholders:
| Placeholder | Value |
|---|---|
{this} |
Source location and visual selection. |
{selection} |
Selected code. |
{file} |
File path, not file contents. |
{filename} |
File name without the directory. |
{position} |
Source location as file:line:col. |
{line} |
Source line text. |
{diagnostics} |
Diagnostics on the source line. |
{diagnostics_all} |
All diagnostics in the source buffer. |
{quickfix} |
Current quickfix entries. |
{buffers} |
Names of loaded, listed buffers. |
{changes} |
Git changes for the source file. |
An unavailable placeholder stays unchanged.
Set placeholders = false on a send item to keep all tokens literal.
See :help wiremux-placeholders for resolution details.
Custom placeholders
Merge configuration examples into your existing options.
A new setup() call replaces the previous configuration.
Add custom resolvers under context.resolvers:
require("wiremux").setup({
context = {
resolvers = {
git_branch = function()
local result = vim.system({ "git", "branch", "--show-current" }, { text = true }):wait()
return result.code == 0 and vim.trim(result.stdout) or nil
end,
},
},
})Use {git_branch} in your messages.
Resolver names start with a letter or underscore and contain only letters, digits, or underscores.
Return a string to replace the placeholder.
Return nil to keep it unchanged, or an empty string to remove it.
Resolvers can read the source origin argument.
It contains bufnr, path, row, col, selection, and line.
These fields stay fixed for each Compose page. Other editor values resolve on confirmation.
Keep resolvers fast. Do not change editor state from a resolver.
Each setup() call replaces custom resolvers and retains built-in resolvers.
Merge these options into your existing configuration.
Target definitions and selection
Add definitions for your tools under targets.definitions:
targets = {
definitions = {
claude = { cmd = "claude", kind = { "pane", "window" }, shell = false },
opencode = { cmd = "opencode", kind = { "pane", "window" }, shell = false },
},
},kind selects a pane or window. A list lets you choose at creation.
shell = false runs the program directly.
To select a named target, pass target:
require("wiremux").send("{this}", { target = "claude" })Wiremux reuses matching instances or creates one from the definition. Filters still apply. Multiple matches can open a picker.
If a definition has no cmd, create its terminal before you send arbitrary text.
Otherwise, Wiremux can use the first payload as its startup command.
| Option | Choices | Purpose |
|---|---|---|
behavior |
pick, last, all |
Choose one target, reuse the last target, or use all matches. |
mode |
auto, instances, definitions, all |
Choose where send() and toggle() look for targets. |
For send(), auto prefers managed instances.
If none pass the filter, Wiremux offers unmanaged panes and definitions.
For a mixed send picker, use mode = "all" with behavior = "pick".
When matching managed instances exist, behavior = "all" sends to all of them.
If none match, Wiremux can offer adoption or creation.
toggle() does not adopt unmanaged panes.
See :help wiremux-configuration for target fields and selection options.
Pickers and filters
If neither fzf-lua nor Snacks is available, Wiremux uses vim.ui.select.
To choose an adapter, add this field to your options:
picker = {
adapter = "fzf-lua", -- or "snacks"
},By default, managed targets belong to the current tmux pane. Adoption candidates include same-session unmanaged panes and targets whose owner no longer exists.
Use a filter to change the candidate list:
picker = {
instances = {
filter = function() return true end,
},
},This filter includes candidates from other sessions. Wiremux excludes the current pane.
filter = nil retains the default filter.
Unmanaged panes can have target = nil.
Before you use target as a string, make sure that inst.managed is true.
See :help wiremux-filters for instance and definition filters.
Compose keymaps and footer
The footer shows only the active Send and Close keys.
Set ui.compose.footer = false to hide it without changing mappings.
Map each action to one keymap or a list of keymaps:
ui = {
compose = {
footer = false,
keymaps = {
send = { { "<F5>", mode = { "n", "i" } } },
close = { { "<Esc>", mode = "n" } },
preview_placeholder = {},
},
},
},An omitted action inherits its mappings. A supplied entry replaces all mappings for that action.
An empty action entry disables its mappings.
An empty outer keymaps table does not clear defaults.
Set ui.compose.use_default_keymaps = false to omit all shipped Compose bindings.
Explicitly configured mappings still apply. This switch is global-only.
Native replacements, option changes, and deletions survive draft refreshes.
See :help wiremux-compose-keymaps for the full rules.
See :help wiremux-config-compose-footer for footer overrides.
Native Compose mappings and actions
Before you open Compose, register this event handler:
vim.api.nvim_create_autocmd("User", {
pattern = "WiremuxComposeOpen",
callback = function(event)
local buf = event.data.buf
vim.keymap.set("n", "<F5>", function()
require("wiremux").compose.send({ buf = buf })
end, { buffer = buf, desc = "Send draft" })
end,
})The event runs after Wiremux installs its mappings. It runs for new or restored windows, not when Wiremux only focuses a visible window.
Pass { buf = buf } so an old callback cannot act on a newer draft.
require("wiremux").compose exposes draft actions such as send(), close(), previous(), and next().
Root require("wiremux").close() closes a tmux target, not a draft.
A true action result means that the handler accepted the action, not that delivery succeeded.
See :help wiremux-compose-api for all actions and return values.
Placeholder completion with blink.cmp or nvim-cmp
Wiremux offers optional completion sources for Compose. They suggest placeholders without executing their resolvers.
blink.cmp
Merge these settings with your existing sources:
completion = { documentation = { auto_show = true } },
sources = {
default = function()
local sources = { "lsp", "path", "snippets", "buffer" }
if vim.b.wiremux_compose == true then
sources[#sources + 1] = "wiremux"
end
return sources
end,
providers = {
wiremux = {
name = "Wiremux",
module = "wiremux.completion.blink",
opts = { preview = true },
},
},
},This source check delays the Wiremux import until you enter Compose.
blink imports a provider before it checks the enabled callback for that provider.
For a Markdown source override, use inherit_defaults = true or the same Compose check.
nvim-cmp
Add { name = "wiremux" } to your sources, including any Markdown override.
After cmp loads, register the source:
local function register_wiremux()
if vim.b.wiremux_compose == true then
require("wiremux.completion.cmp").register({ preview = true })
end
end
vim.api.nvim_create_autocmd("BufEnter", {
group = vim.api.nvim_create_augroup("wiremux_cmp", { clear = true }),
callback = register_wiremux,
})
register_wiremux()The immediate call handles an open Compose buffer. BufEnter handles later visits.
With preview = true, completion documentation shows up to 10 lines and 2,048 bytes.
These limits affect the display, not the message that you send.
Preview resolvers run synchronously. Omit preview for descriptions only.
Keep your usual Wiremux lazy-loading triggers.
See :help wiremux-completion for custom limits and integration details.
Statusline
Add the target count to lualine:
{
require("wiremux").statusline.component(),
padding = { left = 1, right = 1 },
}For heirline or feline, use the component as a provider:
{ provider = require("wiremux").statusline.component() }
statusline.get_info() returns { loading, count, last_used } for custom displays.
Use require("wiremux") for Lua actions or :Wiremux on the command line:
| Lua action | Command | Purpose |
|---|---|---|
send() |
:Wiremux send <text> |
Send text. |
create() |
:Wiremux create |
Create a target. |
toggle() |
:Wiremux toggle |
Show or hide a target. |
focus() |
:Wiremux focus |
Focus a target. |
close() |
:Wiremux close |
Close a target. |
adopt() |
:Wiremux adopt |
Manage an existing pane. |
history() |
:Wiremux history |
Recover a Compose message. |
For motion mappings, see :help wiremux-howto-send-motion.
Adopt an existing pane
Run :Wiremux adopt to choose an existing pane.
Wiremux adds its metadata without a new pane. Wiremux excludes the current pane.
To assign a name and filter the candidates, use Lua:
require("wiremux").adopt({
target = "terminal",
filter = {
instances = function(inst)
return inst.running_command == "zsh"
end,
},
})The default adopt picker uses the current tmux session.
A custom filter replaces that default.
See :help wiremux.adopt() for filtering and row formatting.
- Run
:checkhealth wiremuxfor setup problems. - Make sure that Neovim runs inside tmux and that
$TMUXis set. - Read
:help wiremuxor the reference manual for the full API and configuration.
folke/sidekick.nvim inspired the project and some implementation patterns.
Development included AI-assisted tools. The maintainers reviewed and adjusted all generated code.