Send Code From Vim to a tmux Pane With One Operator

September 24, 2026

The Console

If you work on a Rails app long enough, you end up fixing production from the console. Something's off with a user's subscription, and the fastest way to find out why is to poke at the data directly. (Very carefully. Writing to prod data is how bad days start.)

So how do you get the queries in? You can type them into the console one at a time. That works, but it's slow, and you make typos. A console is a bad place to write code.

More likely, you write the queries somewhere else and paste them in. Now you have four steps: write the code, copy it, switch to the console, paste. You do that dozens of times in a debugging session. It adds up.

What you really want is two steps. Write the code. Send it.

You could use rails runner. But the script usually isn't on the prod server, and you lose the back-and-forth that makes a console useful. You want to see the answer, digest it, and ask the next question.

Vim Verbs

Vim has a name for "do something to this chunk of text": an operator. d deletes, y yanks, c changes. Each one works with every motion and text object, so if you know a dozen motions, one new operator gets you a dozen new commands.

What if there were an operator whose job was to send text somewhere else, say, the console? Call it gs. gss would send the current line to the Rails console. gsj would send the current line and the next one. gs10j would send eleven lines, and gs} would send everything to the end of the paragraph.

It's not hard to build and I'll show you how.

Ingredients

I use Vim inside tmux. You don't need my exact setup; the idea should carry over, though I've never tried it anywhere else.

For my recipe, you need:

  • Vim 8.2.3619 or later (or just Vim 9). Operatorify assigns a Funcref to 'operatorfunc', which Vim added in 8.2.3619. My config also uses #{} dict literals and popup_menu(), which Neovim doesn't have. Neovim has equivalents (like vim.ui.select), but you'd have to port those parts.
  • tmux, with Vim running in one of its panes.
  • vimux, which does the real work of sending keys to a pane.
  • Optionally, vim-operatorify (disclaimer: my plugin), which turns a function into an operator. You don't have to use it, though.
  • Optionally, fzf and fzf.vim, for a nicer pane picker.

None of this is new. vimux was inspired by tslime.vim, which was based on slime.vim, which took its idea from Emacs' SLIME. SLIME dates from 2003. People have been sending text from editors to REPLs for over twenty years. Some of you probably weren't even born yet!

What's missing is the part that makes it feel like Vim.

gs operator

I picked gs because the s is a tribute to slime. It's also in my fingers by now. (If you want the long version of how operators work, I wrote about making your own Vim operator.)

With Operatorify

With operatorify, the setup is two lines of real code:

" plugin/plugins/vimux.vim
function! VimuxSlimeExe(text = '')
  call VimuxRunCommand(a:text)
endfunction

" after/plugin/plugins/vimux.vim
call Operatorify#Mapper('gs', 'VimuxSlimeExe')

VimuxSlimeExe takes a string and hands it to vimux. That's all. It doesn't know it's an operator. Operatorify works out which text you meant, yanks it without disturbing your registers, and passes it in.

So gsiw sends the word under the cursor. gs2j sends the current line and the two below it. gss sends the current line. Every motion you already know now means something new.

By Hand

If you think mentioning operatorify is my subtle way of getting you to use my plugin, you're correct. Just kidding — you don't need my plugin. Here's how you can do it without it:

function! s:SlimeOperator(type) abort
  let l:save_reg   = getreginfo('"')
  let l:save_sel   = &selection
  let l:save_clip  = &clipboard
  let l:save_marks = [getpos("'<"), getpos("'>")]
  try
    set selection=inclusive clipboard=
    if a:type ==# 'line'
      silent noautocmd keepjumps normal! '[V']y
    elseif a:type ==# 'char'
      silent noautocmd keepjumps normal! `[v`]y
    elseif a:type ==# 'block'
      silent noautocmd keepjumps execute "normal! `[\<C-V>`]y"
    endif
    call VimuxRunCommand(getreg('"'))
  finally
    call setreg('"', l:save_reg)
    let &selection  = l:save_sel
    let &clipboard  = l:save_clip
    call setpos("'<", l:save_marks[0])
    call setpos("'>", l:save_marks[1])
  endtry
endfunction

nnoremap <silent> gs  :set operatorfunc=<SID>SlimeOperator<CR>g@
xnoremap <silent> gs  :<C-u>set operatorfunc=<SID>SlimeOperator<CR>gvg@
nnoremap <silent> gss :set operatorfunc=<SID>SlimeOperator<CR>g@_

It looks long, but most of that is bookkeeping. You save the register, the selection option, the clipboard, and the visual marks. Then you yank the text, send it, and put most things back. The actual work is this line: call VimuxRunCommand(getreg('"')).

It has rough edges. It doesn't restore register 0 or the '[ / '] marks, and a count in front (2gss) fails with E481: No range allowed, because : turns the count into a range for :set. Operatorify handles both.

Watch the gv in the visual mapping. :<C-u> drops you out of visual mode. Without gv to reselect, g@ just sits there waiting for a motion.

Aim your text

My tmux windows are messy. I rarely have just two panes. Sometimes I have Vim, a Rails console, and Claude Code. When you press gss, which pane gets the text?

By default, vimux sends to what it calls the nearest pane. That sounds cool until you read the code. "Nearest" means the first pane in the window, by index, that isn't Vim. Vimux picks it once and sticks with it. (If Vim is the only pane, it opens a new split.)

With two panes, that's fine. With three, it's a coin toss. Does it get sent to the Rails console or Claude Code? Imagine Vim, a local console, and a production console. You meant to run User.destroy_all locally, but it got sent to the other console. Uh oh. Don't be that person. Aim first.

How do you aim? I use <Leader>gs for that. gs sends; <Leader>gs aims.

Vimux keeps its target in a global variable, so aiming just means setting it. First, list the other panes:

function! s:GetAvailablePanes() abort
  let current = trim(system("tmux display-message -p '#{pane_id}'"))
  let fmt = '#{pane_id}|#{pane_index}|#{pane_current_command}|#{pane_width}x#{pane_height}|#{pane_title}'
  let panes = split(system("tmux list-panes -F '" . fmt . "'"), '\n')
  " ... parse on '|', skip the pane Vim is sitting in, return dicts
endfunction

Then you pick one. If fzf is installed, I use fzf#run. If not, I fall back to Vim's popup_menu(), and failing that, a numbered input() prompt. Ultimately, it ends up here:

function! s:SetVimuxRunner(pane_id) abort
  let g:VimuxRunnerIndex = a:pane_id
  echo "Vimux runner set to pane " . a:pane_id
endfunction

VimuxRunCommand sees g:VimuxRunnerIndex already set and sends there. No more coin toss. (If you've closed that pane since, the next send opens a new split.)

Use it

I have Vim on top, Rails console below.

First, aim. <Leader>gs lists every other pane in the window, and I pick the one running ruby. (If the console is on a remote box, the pane shows ssh or kubectl instead.) With two panes you can skip this step.

Then I send. gs is an ordinary operator, so it's verb plus motion:

| You want | Keys | |---|---| | Run the line under the cursor | gss | | Run the inside of a { } block | gsi{ | | Run a paragraph (e.g. a def block with no blank lines) | gsip | | Run an ad-hoc multi-line snippet | visual-select, then gs | | Run the current line and the next four | gs4j | | Send the next line too | j. |

The one I use most is gss. After that, . re-sends the same line and j. sends the next one. Three keys the first time, then one or two. No window switching, no mouse, no clipboard.

Scratch

I keep my queries in scratch files, one per problem, named by date:

# 2026_09_09.scratch.rb
reload!
u = User.find_by(email: "someone@example.com")
u.subscriptions.active.pluck(:plan_id)
u.errors.full_messages

Then I walk down the file with gss, or fire the whole thing with gsip. If something needs changing, I edit it and hit gss again.

Next problem, next file:

# 2026_09_09.scratch2.rb
reload!
acc = Account.find(10)
acc.active?

The next time something similar breaks, the queries are already there.

This Flow Landed Me A Job

The general lesson is this. Whenever you write a mapping that does something to text, ask whether it should be an operator. An operator multiplies whatever you wrote by every motion and text object you know.

My flow is not original. vim-slime is the modern descendant of slime.vim, and it has its own operator, <Plug>SlimeMotionSend. I built mine before I found out about it. I also haven't switched because I use vimux for other things, like running specs.

This flow once helped me get a job. It was a timed coding interview. I had one hour to solve a few bugs. I had a Rails console for checking data. I wrote each Active Record query once and ran it with gss. Copy-pasting would have cost a few seconds every time. I finished with less than a minute left. A few seconds doesn't sound like much, but spread it across an hour, it's the difference between finishing and not.

Not a Vim user? Vimux is mostly a wrapper around tmux commands. tmux send-keys -t %12 'User.count' Enter sends a "User.count" into pane %12. For a longer chunk of text, tmux load-buffer - then tmux paste-buffer -t %12 does the same job. Any editor or IDE that can run a shell command can do this. I'd ask Claude: "How do I send a body of text to tmux pane X from my editor?"