Ergonomic SSH development

I’ve been setting up a dev machine on my home server and doing all of my personal projects remotely over SSH. I’ve fiddled quite a bit with the system to make it ergonomic, so here are some of the things I’m quite happy with.

Tmux autocompletions

Of course, tmux is a t-must! One of the cool things about tmux is that you can programmatically read the contents of a pane via tmux capture-pane -S- -pt $PANE_ID, which means you can plug this data into autocomplete engines.

zsh tmux autocomplete config
# Complete the current word from words in all tmux pane.
_tmux_pane_words() {
  [[ -n $TMUX ]] || return 1
  local pane text
  for pane in ${(f)"$(tmux list-panes -a -F '#{pane_id}')"}; do
    text+="$(tmux capture-pane -p -J -S - -t $pane)"$'\n'
  done
  local -a words
  words=(${(u)=${text//[^[:alnum:]_.@\/-]/ }})  # tokenize + dedup
  words=(${words:#$PREFIX})   # exclude the currently typed word
  compadd -a words
}
zmodload zsh/complist
zle -C tmux-complete menu-select _tmux_pane_words
bindkey '^N' tmux-complete
bindkey -M menuselect '^N' down-line-or-history
bindkey -M menuselect '^P' up-line-or-history

I got this working using a zsh custom completion function, along with vim-style ^N and ^P to cycle between completions.

But why should zsh have all the fun? neovim has autocomplete too!

neovim tmux autocomplete config
-- Complete words from all other tmux panes.
function _G.tmux_complete(findstart, base)
  if findstart == 1 then                         -- locate start of the keyword
    local line, col = vim.fn.getline("."), vim.fn.col(".") - 1
    local s = col
    while s > 0 and line:sub(s, s):match("%S") do s = s - 1 end
    return s
  end
  if not vim.env.TMUX then return {} end
  local words, seen = {}, {}
  local self = vim.env.TMUX_PANE                  -- skip nvim's own pane
  for _, pane in ipairs(vim.fn.systemlist({ "tmux", "list-panes", "-a", "-F", "#{pane_id}" })) do
    if pane ~= self then
      local text = vim.fn.system({ "tmux", "capture-pane", "-p", "-J", "-S", "-", "-t", pane })
      for w in text:gmatch("%S+") do
        if #w > 2 and not seen[w]
           and (base == "" or w:sub(1, #base):lower() == base:lower()) then
          seen[w] = true
          words[#words + 1] = { word = w, menu = "[tmux]", dup = 0 }
        end
      end
    end
  end
  return words
end

vim.o.completefunc = "v:lua.tmux_complete"
vim.opt.complete:append("F")   -- F = use 'completefunc' as a <C-n> source

Since I do pretty much all of my work in tmux, nvim, and zsh, these make it really easy to autocomplete anything I’m looking at.

OSC-52

But the real unsung hero of any workflow is the humble copy-and-paste. The ‘ol <C-c> <C-v>. It turns out there’s a thing called OSC-52 that enables copy and paste over SSH. Most terminals support this, so it’s a matter of enabling it everywhere (tmux, nvim, and zsh).

I accomplished this via custom yank and put scripts (defined at the end, for dramatic effect).

tmux yank config

This configures tmux to use a yank script that needs to exist on $PATH (defined in the zsh section below).

set -g allow-passthrough on
set -g set-clipboard on
set -as terminal-features '*:extkeys:clipboard'

bind-key -T copy-mode-vi 'v' send -X begin-selection
bind-key -T copy-mode-vi 'y' send -X copy-pipe "yank"
bind-key -T copy-mode-vi 'Enter' send -X copy-pipe-and-cancel "yank"

neovim yank/put config

Similarly, this relies on a yank and put script available on $PATH.

-- Custom clipboard commands.
vim.o.clipboard = "unnamedplus"
vim.g.clipboard = {
  name = 'OSC-52 + local clipboard',
  copy = {
    ["+"] = {'yank'},
    ["*"] = {'yank'},
  },
  paste = {
    ["+"] = {'put'},
    ["*"] = {'put'},
  },
  cache_enabled = true,
}

zsh yank/put scripts

And finally, the custom yank and put scripts. These scripts live on my remote machine, which allows copying to my local machine over SSH and pasting within the remote machine. OSC-52 supports pasting over SSH too, but the terminal asks you every time, which I found annoying (especially in nvim).

# yank
#
# Script to save text to /tmp/clipboard and copy it via tmux or OSC-52. If args
# are passed, that takes precedence.

# If args are passed, copy those, otherwise, read stdin.
if [ $# -gt 0 ]; then
  content="$*"
else
  # Use process substitution to prevent command substitution from
  # stripping trailing newlines.
  IFS= read -rd "" content < <(cat)
fi
                                                                  
printf '%s' "$content" > /tmp/clipboard
if [ -n "$TMUX" ]; then
  tmux set-buffer -w -- "$content"
else
  printf "\033]52;c;%s\007" $(base64 -w0 <<< "$content")
fi
# put
#
# Script to paste text written to /tmp/clipboard.
cat /tmp/clipboard

Slime

slime is an emacs thing for REPL-driven development, and vim-slime adds some of those features to vim!

vim-slime config

By default, vim-slime asks for a socket and target pane. I always use default for the socket name, so I added an override to only ask for the target pane.

-- Slime configuration.
vim.g.slime_target = "tmux"
vim.g.slime_default_config = { socket_name = "default", target_pane = "{last}" }
vim.keymap.set("x", "<Enter>", "<Plug>SlimeRegionSend", { remap = true })
vim.keymap.set("n", "<Leader>s", "<Plug>SlimeConfig", { remap = true })
-- Only ask for the target pane.
vim.cmd([[
  function! SlimeOverrideConfig()
    if !exists("b:slime_config")
      let b:slime_config = {"socket_name": "default", "target_pane": ""}
    endif
    let b:slime_config["socket_name"] = "default"
    let b:slime_config["target_pane"] = input("tmux target pane: ", b:slime_config["target_pane"], "custom,slime#targets#tmux#pane_names")
  endfunction
]])

I also added this to my tmux config to make finding the target pane ID easier. {last} uses the last pane you navigated from, but it evaluates every time, so I like to use the constant pane ID.

set -g pane-border-status top
set -g pane-border-format ' #{pane_id} '

I find this to be quite useful combined with the nvim/tmux autocomplete defined earlier in this post.

Conclusion

I’ve always favored keyboard-driven development, and now that I’m leaning into remote development over SSH, these configurations have really made it so much easier to forget I’m not working locally. It’s just a joy to have things work seamlessly.