Skip to content

Instantly share code, notes, and snippets.

@pbnj
Last active May 3, 2024 13:04
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save pbnj/67c16c37918ba40bbb233b97f3e38456 to your computer and use it in GitHub Desktop.
Save pbnj/67c16c37918ba40bbb233b97f3e38456 to your computer and use it in GitHub Desktop.
Toggle-able Terminal in Tmux
bind-key -n 'C-\' run-shell -b ${HOME}/.local/bin/tmux-toggle-term

Toggle-able Terminal in Tmux

For Vim, Neovim, Helix, or any terminal-based editor

Introduction

(Neo)Vim users are likely familiar with the integrated :terminal. Any time you need to compile a program or start a long running process, the :terminal is always near-by.

Popular plugins, like vim-floaterm and toggleterm.nvim add some nice ergonomics, like floating windows and key mappings for toggling the terminal.

I have been a long-time user of the integrated terminal until I started encountering long text outputs, like URLs, that the integrated terminal hard-wraps them mid-word, instead of soft-wrap.

This meant that what should have been a one-step task of clicking a URL or copy-pasting text into a Vim buffer has now become a multi-step process of fixing text by removing line returns. Doing this over-and-over, especially for large outputs (e.g. logs) gets very tedious very quickly.

Let's explore how we can accomplish a similar experience as vim-floaterm and toggleterm.nvim, but leveraging tmux instead.

Getting Started

Tmux is a powerful utility. You can configure it using ~/.tmux.conf configuration file and you can even drive it from within itself (see man tmux for usage details). For example, tmux split-window will split the current tmux window into 2 vertical panes.

At a high-level, what we need to accomplish is this:

  • Bind ctrl-\ to be the keyboard shortcut for toggling the terminal in tmux.
  • If the current window has only 1 pane, pressing ctrl-\ should create a new pane for the terminal.
  • If the current window has 2 panes, pressing ctrl-\ should toggle the terminal pane.

First Iteration

Let's set up the key binding to either create a new split or to toggle based on the current number of panes, like:

bind-key -n 'C-\' if-shell '[ "$(tmux list-panes | wc -l | bc)" = 1 ]' {
  split-window
} {
  resize-pane -Z
}

Explanation:

  • bind-key -n: this allows us to define keybindings that do not require the tmux prefix or modifier key (ctrl-b by default)
  • if-shell '<condition>' { true } { false }: this allows us to evaluate some condition and execute the 1st-block if true, otherwise execute the 2nd block.
  • [ "$(tmux list-panes | wc -l | bc)" = 1 ]: we list the current panes, count the lines, pipe it through a calculator. If we only have 1 pane, then we run split-window to create a new pane, otherwise we leverage the zoom feature to maximize the current pane (i.e. the pane that has the cursor) to fill the entire tmux window (thus hiding the other pane).

This is already a great start. For some, this might be all that you need.

The experience looks like this:

  1. Toggle the terminal with ctrl-\
  2. Move the cursor to the next pane with ctrl-b + o, or ctrl-b + <down> arrows, or even focus the pane with the mouse (if you configure set-option -g mouse on).
  3. When done with the terminal pane, focus the main pane and hit ctrl-\

Having to manually focus tmux panes introduces a bit of friction.

Let's improve this.

Second Iteration

For an experience closer to toggleterm.nvim, where ctrl-\ toggles and focuses the terminal, then ctrl-\ again hides the terminal and returns the cursor to the main pane, we need to tweak the second branch (i.e. the resize-pane logic) to make it a little smarter, like:

bind-key -n 'C-\' if-shell '[ "$(tmux list-panes | wc -l | bc)" = 1 ]' {
- split-window
+ split-window -c '#{pane_current_path}'
} {
- resize-pane -Z
+ if-shell '[ -n "$(tmux list-panes -F ''#F'' | grep Z)" ]' {
+   select-pane -t:.-
+ } {
+   resize-pane -Z -t1
+ }
}

Explanation:

  • tmux list-panes -F '#F': this lists panes in a custom format. The #F tells tmux to print the "pane flags". Zoomed panes have a Z flag.
  • If there is a zoomed pane (i.e. the terminal pane is hidden), then we switch focus (via select-pane) to the previous one (i.e. -t:.-).
  • Otherwise, there is no zoomed pane (i.e. the terminal split pane is shown), so we zoom into pane 1 (via resize-pane -Z -t1).

This is it. We have implemented the core functionality to be able to toggle terminals with a few lines of tmux configuration.

But, there is still room for improvement. For those who prefer floating terminal windows, this is for you.

Third Iteration

Let's toggle the terminal in a floating window. Because of the added bit of complexity here, let's abstract the functionality into a reusable shell script that we can extend further as needed.

Tmux has a popup feature. In the most basic scenario, you can run tmux popup inside a tmux session (or ctrl-b + :, then type popup and hit <ENTER>) and a floating window will appear with a terminal shell. However, I was not able to find a way to toggle this popup window. So, we will combine pop-ups with tmux sessions so we can detach and attach as the toggle mechanism.

Create a file called it tmux-toggle-term somewhere in your $PATH (e.g. ~/.local/bin) with the following content:

#!/bin/bash

set -uo pipefail

FLOAT_TERM="${1:-}"
LIST_PANES="$(tmux list-panes -F '#F' )"
PANE_ZOOMED="$(echo "${LIST_PANES}" | grep Z)"
PANE_COUNT="$(echo "${LIST_PANES}" | wc -l | bc)"

if [ -n "${FLOAT_TERM}" ]; then
  if [ "$(tmux display-message -p -F "#{session_name}")" = "popup" ];then
    tmux detach-client
  else
    tmux popup -d '#{pane_current_path}' -xC -yC -w90% -h80% -E "tmux attach -t popup || tmux new -s popup"
  fi
else
  if [ "${PANE_COUNT}" = 1 ]; then
    tmux split-window -c "#{pane_current_path}"
  elif [ -n "${PANE_ZOOMED}" ]; then
    tmux select-pane -t:.-
  else
    tmux resize-pane -Z -t1
  fi
fi

Explanation:

  • We expose the floating window behind a FLOAT_TERM flag
  • If FLOAT_TERM string is not empty, then we check the session name. If session name of popup exists, then we detach, otherwise we attempt to attach. If attach fails, then we create a new session.
  • If FLOAT_TERM string is empty, then we fallback to split panes with the same logic as before.

Next, update your ~/.tmux.conf to replace the previous config from the 1st or 2nd iterations with this:

# for splits
bind-key -n 'C-\' run-shell -b "${HOME}/path/to/tmux-toggle-term"

# or, for floats
bind-key -n 'C-\' run-shell -b "${HOME}/path/to/tmux-toggle-term float"

Tip: (Neo)Vim users who enjoy using the integrated terminal with buffer completion in insert-mode to avoid copy/paste can still accomplish a similar with https://github.com/wellle/tmux-complete.vim or https://github.com/andersevenrud/cmp-tmux

Conclusion

In this post, we have seen how we can accomplish so much with so little.

I hope this inspired you to find ways to make your workflows more efficient and productive.

#!/bin/bash
set -uo pipefail
FLOAT_TERM="${1:-}"
LIST_PANES="$(tmux list-panes -F '#F' )"
PANE_ZOOMED="$(echo "${LIST_PANES}" | grep Z)"
PANE_COUNT="$(echo "${LIST_PANES}" | wc -l | bc)"
if [ -n "${FLOAT_TERM}" ]; then
if [ "$(tmux display-message -p -F "#{session_name}")" = "popup" ]; then
tmux detach-client
else
tmux popup -d '#{pane_current_path}' -xC -yC -w90% -h80% -E "tmux attach -t popup || tmux new -s popup"
fi
else
if [ "${PANE_COUNT}" = 1 ]; then
tmux split-window -c "#{pane_current_path}"
elif [ -n "${PANE_ZOOMED}" ]; then
tmux select-pane -t:.-
else
tmux resize-pane -Z -t1
fi
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment