Honing the Tools (II) - WezTerm

To take off on this journey to find a new set of very, very sharp tools, let’s start with the basics: If the aim is having a set of atomic, CLI applications to solve my problems, I’d better find a very good terminal emulator to hold everything together. WezTerm is built on Rust, and is the reason why I am changing my mind regarding my daily tools. After testing plenty of terminal emulators for macOS, they all left a bittersweet taste. The one terminal emulator that is always mentioned is iTerm 2, which is a very good terminal emulator but felt really clunky to configure and had a lot of features that I personally don’t need. The staples on GNU/Linux environments like kitty or alacritty feel badly integrated or half-baked in macOS. I’m not sure if WezTerm is the best tool, but it is certainly the best one for my needs. Let’s take a quick tour around its configuration options to check how I can make the perfect one for me.

How to configure WezTerm

The WezTerm documentation is a short and concise web that can easily be read top-to-bottom and provides an overview of all the possible settings available. Since it is already structured sensibly, we will stick to it. All these settings are expected to appear in a Lua config file that will return the modified configuration. The default option is ~/.wezterm.lua, but since I have an already set repository, I will use a symlinked ~/.config/wezterm/wezterm.lua file instead. The basic structure is:

1
2
3
4
5
6
local wezterm = require 'wezterm'
local config = wezterm.config_builder()

-- Configure everything here on the `config` object

return config

This file is watched by WezTerm and will hot-reload on every change to it. There is nothing too interesting in the first section apart from this, except the fact that on Windows there is a thing called thumb-drive mode that will load the configuration if there is a valid file next to the wezterm.exe file being used. It also provides an overview of how to split the configuration into several files, but we will not be using it for this post.

Appearance & Font Configuration

There is plenty of granularity when defining colors in WezTerm, where plenty of individual colors can be tuned to one needs. For the sake of my mental health, we will be glossing over this and instead choosing a color scheme. Even though in VS Code I use both the light and dark versions of the GitHub color scheme, I think this is a good chance for me set again the Catppuccin theme. I think it’s a really pretty color scheme with very nice contrast both in its light and dark versions. Setting a built-in color scheme is as easy as using the config.color_scheme object, and the themes are included in WezTerm. However, there is a snippet that we will use this function to make the configuration easier to read:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
---Return the suitable argument depending on the appearance
---@param arg { light: any, dark: any } light and dark alternatives
---@return any
local function depending_on_appearance(arg)
  local appearance = wezterm.gui.get_appearance()
  if appearance:find 'Dark' then
    return arg.dark
  else
    return arg.light
  end
end

This is hardly an impressive function, but it is great to remember that Lua is a complete programming language. We can define real functions and take advantage of this to make easier settings or don’t repeat ourselves, but also to make more complex configurations. Let’s put this function to work:

1
2
3
4
config.color_scheme = depending_on_appearance {
  light = 'Catppuccin Latte',
  dark = 'Catppuccin Mocha',
}

Lovely, isn’t it? Now, every time the system setting changes (in my configuration, this happens at sunset and sunrise), WezTerm will detect it and reload the configuration, which will make the color scheme change according to the system setting. This is one of the things that pleased me about WezTerm, this attention to detail; no other tool of this kind I have used before lets you do this as easy as this.

Now that the colors should be mostly done, let’s focus on the last detail: the tab bar does not match the appearance. That is because the tab bar enabled by default is not themed; instead, it must be themed manually. I decided to go against the grain in this topic and try the “retro” bar, which I liked it better. It is a bit Spartan with its boxy looks, but it works perfectly fine, and I don’t have to cherry pick all the colors (just the accent and foreground of the active one). This snippet will get us there:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
config.use_fancy_tab_bar = false
config.tab_max_width = 32
config.colors = {
  tab_bar = {
    active_tab = depending_on_appearance {
      light = { fg_color = '#f8f8f2', bg_color = '#209fb5' },
      dark = { fg_color = '#6c7086', bg_color = '#74c7ec' },
    }
  }
}

On the aesthetic side, we have just one more thing to take care of: the font configuration. I am a loyal JetBrains Mono user, and I like to increase its weight to make it easier to read (especially in non-Retina displays). This will get us there.

1
2
3
4
5
config.font_size = 14
config.font = wezterm.font {
  family = 'JetBrains Mono',
  weight = 'DemiBold',
}

There are also some advanced font shaping options that I don’t need to use, but can be interesting if you want to configure the ligatures in your font of choice in greater detail.

Behavior

There are some more miscellaneous settings that we can configure for us to fine tune everything to our liking. These variables are not as easy to find and I am probably missing several of them, but after a quick look at the manual these are the ones that I can take advantage of.

First off, let’s make the fullscreen mode be the native macOS one to be able to access the time and menu bar.

1
config.native_macos_fullscreen_mode = true

Then, let’s take back one of my favorite i3 features: set the pane focus by hovering over with the mouse.

1
config.pane_focus_follows_mouse = true

Finally, let’s set the default program (our shell of choice) and the launch menu entries. I am not a heavy user of this feature and I still have to polish it a bit: right now it has these entries plus a wealth of other commands, which I find a bit too cluttered to use it more.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
config.default_prog = { '/opt/homebrew/bin/fish', '-l' }
config.launch_menu = {
  {
    label = 'fish',
    args = { '/opt/homebrew/bin/fish', '-l' },
  },
  {
    label = 'bash',
    args = { '/bin/bash', '-l' },
  }
}

Mouse & Key Bindings

This is, for me, the main section of the configuration. The ability to configure different key bindings that easily makes using WezTerm a breeze and a joy. First off, I have a single entry on the mouse events:

1
2
3
4
5
6
7
8
config.mouse_bindings = {
  -- Open URLs with CMD+Click
  {
    event = { Up = { streak = 1, button = 'Left' } },
    mods = 'CMD',
    action = wezterm.action.OpenLinkAtMouseCursor,
  }
}

Which simply instructs WezTerm to open URL clicks when pressing CMD. Right now it works most of the times, but I currently struggle to open URLs when the open process is nvim.

The rest of the entries in this list will be enclosed in a huge table entry:

1
2
3
config.keys = {
  -- ...
}

The following is very opinionated and probably you will find it chosen by a mad man, but I think it can still be interesting to see how a set of complete bindings look so that everyone gets inspiration without having the actual pains I had before completing it, and for you to have an easier time setting your own bindings.

The first two entries launch the tab navigator (a fuzzy finder for open tabs) and the launcher menu (the feature I mentioned before when setting config.launch_menu).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  -- Show tab navigator
  {
    key = 'p',
    mods = 'CMD',
    action = wezterm.action.ShowTabNavigator
  },
  -- Show launcher menu
  {
    key = 'P',
    mods = 'CMD|SHIFT',
    action = wezterm.action.ShowLauncher
  },

Then, it comes time to set splits: one key for horizontal splits and another one for vertical ones will set us up to use the tiling multiplexer. As you can see, I usually struggle to remember the, so the mnemotechnics I chose are quite obvious:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  -- Vertical pipe (|) -> horizontal split
  {
    key = '\\',
    mods = 'CMD|SHIFT',
    action = wezterm.action.SplitHorizontal {
      domain = 'CurrentPaneDomain'
    },
  },
  -- Underscore (_) -> vertical split
  {
    key = '-',
    mods = 'CMD|SHIFT',
    action = wezterm.action.SplitVertical {
      domain = 'CurrentPaneDomain'
    },
  },

Next is a quick function to rename tabs: the default names are very well thought but they are as useful as generically inferred names can be. Nothing beats a custom name if one can be given, especially for long-lived tabs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  -- Rename current tab
  {
    key = 'E',
    mods = 'CMD|SHIFT',
    action = wezterm.action.PromptInputLine {
      description = 'Enter new name for tab',
      action = wezterm.action_callback(
        function(window, _, line)
          if line then
            window:active_tab():set_title(line)
          end
        end
      ),
    },
  },

Now comes time for all bindings for navigation: first, by arguably the most effective one for general movement, by using the pane selector. This action will display an overlay showing a letter from the home row (asdfgh...) over each pane. To move to any pane, simple type the appropriate letter and that pane will gain focus.

1
2
3
4
5
  -- Move to a pane (prompt to which one)
  {
    mods = "CMD", key = "m",
    action = wezterm.action.PaneSelect
  },

However, since I normally have simpler splits, usually I use vim keybindings to move from one pane to the other using the appropriate direction.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  -- Use CMD + [h|j|k|l] to move between panes
  {
    key = "h",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Left')
  },

  {
    key = "j",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Down')
  },

  {
    key = "k",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Up')
  },

  {
    key = "l",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Right')
  },

Sometimes, though, I just want to move to “the other” pane. This movement can be done using 'Prev' and 'Next' as directions, to traverse the different leaves of the pane split tree:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  -- Move to another pane (next or previous)
  {
    key = "[",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Prev')
  },

  {
    key = "]",
    mods = "CMD",
    action = wezterm.action.ActivatePaneDirection('Next')
  },

A similar movement is to moving to the previous or next tab, which is bound to the same keys using the extra Shift modifier.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  -- Move to another tab (next or previous)
  {
    key = "{",
    mods = "CMD|SHIFT",
    action = wezterm.action.ActivateTabRelative(-1)
  },

  {
    key = "}",
    mods = "CMD|SHIFT",
    action = wezterm.action.ActivateTabRelative(1)
  },

I was not able to find a documented and effective way to “drag” panes to move them across the screen (although there is a rotate function, I usually don’t need to move all of them). When I needed to do so, I found that swapping two panes (one being the current active one and the other being selected using a similar overlay to that of PaneSelect) is the best alternative for me.

1
2
3
4
5
6
7
8
  -- Use CMD+Shift+S t swap the active pane and another one
  {
    key = "s",
    mods = "CMD|SHIFT",
    action=wezterm.action{
      PaneSelect = { mode = "SwapWithActiveKeepFocus" }
    }
  },

I also need quick keybindings to close both panes and tabs…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  -- Use CMD+w to close the pane, CMD+SHIFT+w to close the tab
  {
    key = "w",
    mods = "CMD",
    action = wezterm.action.CloseCurrentPane { confirm = true }
  },

  {
    key = "w",
    mods = "CMD|SHIFT",
    action = wezterm.action.CloseCurrentTab { confirm = true }
  },

One of my favourite functions is zoom state, in which a pane temporarily becomes the single one displayed in the screen. This is very useful to deal with tabs with several splits in small screens, like a laptop’s.

1
2
3
4
5
6
  -- Use CMD+z to enter zoom state
  {
    key = 'z',
    mods = 'CMD',
    action = wezterm.action.TogglePaneZoomState,
  },

Finally, one of the features I have severely underused up to now is binding complex commands to keybindings. Right now, I only have one that creates a new pane running lazygit.

1
2
3
4
5
6
7
8
  -- Launch commands in a new pane
  {
    key = 'g',
    mods = 'CMD',
    action = wezterm.action.SplitHorizontal {
      args = { os.getenv 'SHELL', '-c', 'lg' },
    }
  }

I find this super useful because the pane is closed as soon as we exit lazygit, restoring the original structure of splits. This did not happen when I tried to use the argument top_level = true in SplitPane, which created a full size split on the side but left the empty space. This has been reported in the GitHub, but there is still no information at the time of posting this.

What’s Left

This is the configuration I run on my daily driver. I use WezTerm most of the time already, since I am working on taming down Neovim (for yet another entry of this series). It is a great setup, and I am delighted with it, but there are still some features I have to check out. Most notably, I would like to take a deeper look at WezTerm’s sessions and workspaces, trying to understand if there is a way to define sessions declaratively (much like what tmuxifier offers). However, this is certainly not a blocker for me to use it daily, and no terminal multiplexer I have used before has sparked this joy in me when using it.