Cosimo Matteini

Automate terminal configurations with WezTerm

3 April 2023

9 min read

In the last few months I’ve been trying to automate my terminal configuration for my work/personal projects. Every day having to go through the same process: open a terminal, create tabs and panes, and try to remember which command to run for each project (luckily shell history with fzf helps here!).

So my journey to automate terminal configurations has begun!

Bash & keyboard simulation

I already knew that with xdotool you could simulate keyboard input, so my first attempt was using this tool to simulate my terminal emulator, gnome terminal, shortcuts.

After a few attempts, I managed to get something working (most of the time at least). In the last year I’ve been writing scripts like this, for example this one is used to develop this website:

website.sh
#!/usr/bin/env bash
 
REPOSITORY="$HOME/dev/website"
 
# [s]etup website
swebsite(){
  cd "$REPOSITORY" || exit
 
  local DELAY=1
  # Retrive the active window id
  WID=$(xprop -root | grep "_NET_ACTIVE_WINDOW(WINDOW)" | awk '{print $5}')
  xdotool windowfocus "$WID"
 
  # Start typecheck watcher
  xdotool key ctrl+shift+t
  #           ^ create a new tab shorcut
  xdotool sleep "$DELAY"
  xdotool type --clearmodifiers "npm run typecheck:w"
  xdotool key Return
 
  # Start nextjs server
  xdotool key ctrl+shift+t
  xdotool sleep "$DELAY"
  xdotool type --clearmodifiers "npm run dev"
  xdotool key Return
 
  # Focus first tab and clear
  xdotool key alt+1
  xdotool key ctrl+l
 
  wmctrl -i -a "$WID" # go to that window (WID is numeric so use -i)
}

In order to use this script you need to source it into your shell (I did it inside ~/.bashrc), and then after opening a new terminal you can run:

$ swebsite

So far so good, right? Yeah…until you accidentaly move your mouse/touchpad, type something on your keyboard or focus another application. This would interfere with xdotool commands and your terminal would be left in an inconsistent state.

This worked fine most of the times, but it could also just break randomly without understanding why. Another big issue if you use Linux like me, is that this only works on X11 and not on Wayland, and I’m using both on different laptops.

It was time to look for alternatives.

Terminal multiplexers

Probably someone while reading this said: “Why doesn’t he use a terminal multiplexer?”

Yes you’re right, let’s try zellij and tmux.

zellij

The first impressions were that zellij is great software! Great design and usability without even touching the config file (just like tmux…)!

The killer feature for me was its layout system:

Layouts are text files that define an arrangement of zellij panes and tabs.

Let’s try to create the website configuration, but this time I can also use panes that are not available in gnome terminal:

~/.config/zellij/layouts/website.kdl
layout {
    default_tab_template {
        pane size=1 borderless=true {
            plugin location="zellij:tab-bar"
        }
        children
        pane size=2 borderless=true {
            plugin location="zellij:status-bar"
        }
    }
 
    tab cwd="/home/devmatteini/dev/website" {
        pane split_direction="vertical" {
            pane
            pane split_direction="horizontal" {
                pane {
                    command "npm"
                    args "run" "typecheck:w"
                }
                pane {
                    command "npm"
                    args "run" "dev"
                }
            }
        }
    }
}

This is the result:

website zellij layout

I used zellij for a couple of weeks, and worked pretty nice! The main disadvantage that made me look for something else, was the command in the layout file:

  • If you wanted to stop it to change to another command the pane would also close (not a deal breaker but inconvenient)
  • Integration with direnv (to automatically source environment variables from a .envrc) for commands that needed environment vars to run didn’t work

tmux

I didn’t spend too much time trying tmux unfortunately. I found some promising plugins like tmuxinator, tmux resurrect, teamocil but the overall experience felt worse after trying zellij in terms of configuration and ease of use.

I will save tmux as the last resource if I won’t find anything else.

WezTerm

When you search for the best terminal emulators you always find alacritty, kitty and iTerm2 but very few talks about WezTerm.

WezTerm is a hidden gem! A cross platform GPU-accelerated terminal emulator and multiplexer written in Rust, so it’s blazingly fast. It has a lots of features like tabs, panes and splits, configuration file in lua (with hot reloading) and many more.

The configuration file written in lua opens to a lot of cool things to do, and most importantly it will allow to automate the terminal configuration for my projects!

gui-startup event

This event is useful for starting a set of programs in a standard configuration to save you the effort of doing it manually each time.

Nice, just what I need 🤞. Let’s open the wezterm.lua configuration file and try to use this event:

~/.config/wezterm/wezterm.lua
local wezterm = require "wezterm"
local mux = wezterm.mux
--    ^ multiplexer
 
wezterm.on('gui-startup', function()
  local project_dir = wezterm.home_dir .. '/dev/website'
 
  local tab, main_pane, window = mux.spawn_window {
    cwd = project_dir,
  }
  local typecheck_pane = main_pane:split {
    direction = 'Right',
    cwd = project_dir,
  }
  typecheck_pane:send_text 'npm run typecheck:w\n'
  -- NOTE: '\n' will execute the command, otherwise it will be only pasted
 
  local nextjs_pane = typecheck_pane:split {
    direction = 'Bottom',
    cwd = project_dir,
  }
  nextjs_pane:send_text 'npm run dev\n'
end)
 
return {
    -- Terminal configuration options (fonts, color scheme...)
}

Now if I open a new terminal, this configuration will be created automatically!

WezTerm website terminal configuration

Different projects configurations

Ok, it works the way I wanted. Now let’s face the main issue: What happens if I have to work on another project that is not the website?

One solution is to add an environment variable with the project name you want to start and use it inside the gui-startup callback to setup the configuration for that project.

~/.config/wezterm/wezterm.lua
wezterm.on('gui-startup', function()
  local project = os.getenv("WZ_PROJECT")
 
  if project == "website" then
    local project_dir = wezterm.home_dir .. '/dev/website'
    -- [...]
  end
end)

If I open a new terminal, nothing gets created since WZ_PROJECT is not set. But if I run this command to start a new terminal, it will be properly created:

WZ_PROJECT=website wezterm start --always-new-process &
# NOTE: without '--always-new-process', the gui-startup event will not be triggered again

Every time I need a new project configuration, I can add a new if statement with the configuration setup.

Refactoring

We’re almost done. What I would like to do before wrapping up is to move all projects configurations out of the main wezterm config file for two reasons:

  1. As projects configurations increase, readability decreases
  2. wezterm.lua is committed inside my dotfiles git repository, and I don’t want it to contains all work/personal configurations.

I started by creating a new lua file project.lua into the same directory as wezterm.lua (in my case ~/.config/wezterm). This lua module will be responsible to find and configure the specified WZ_PROJECT:

~/.config/wezterm/project.lua
local file_exists = function(name)
    local file = io.open(name, "r")
    if file ~= nil then
        io.close(file)
        return true
    else
        return false
    end
end
 
local startup = function(env_var, base_path, wezterm)
    local project = os.getenv(env_var)
 
    if project == nil then return end
 
    -- Check that the file actually exists
    local project_path = base_path .. "/" .. project
    if not file_exists(project_path) then
        wezterm.log_error("Project file does not exist: " .. project_path)
        return
    end
 
    local project_config = dofile(project_path)
    --                       ^ this import and execute a lua file (because it's not in package.path)
    local project_startup = project_config.startup
    -- Here I defined the contract that a project config needs to export a function called `startup`.
 
    -- Check the project_startup is set and is a function
    if project_startup == nil or type(project_startup) ~= "function" then
        wezterm.log_error("Project " ..
            project .. " has no exported 'startup' function (type is " .. type(project_startup) .. ")")
        return
    end
 
    -- Finally let's setup the project
    project_startup(wezterm)
end
 
return {
    startup = startup
}

Now we can open wezterm.lua again and change gui-startup callback:

~/.config/wezterm/wezterm.lua
local project = require "project"
 
wezterm.on('gui-startup', function()
  local base_dir = wezterm.config_dir .. "/projects"
  project.startup("WZ_PROJECT", base_dir, wezterm)
end)

Next, we need to create the base_dir directory (which in my case resolves to ~/.config/wezterm/projects/) and then we can create a file inside it called website.lua:

~/.config/wezterm/projects/website.lua
local function startup(wezterm)
    local mux = wezterm.mux
    local project_dir = wezterm.home_dir .. '/dev/website'
 
    local tab, main_pane, window = mux.spawn_window {
        cwd = project_dir,
    }
 
    local typecheck_pane = main_pane:split {
        direction = 'Right',
        cwd = project_dir,
    }
    typecheck_pane:send_text 'npm run typecheck:w\n'
 
    local nextjs_pane = typecheck_pane:split {
        direction = 'Bottom',
        cwd = project_dir,
    }
    nextjs_pane:send_text 'npm run dev\n'
end
 
return {
    startup = startup
}

Now open a new terminal and run the same command as before:

WZ_PROJECT=website.lua wezterm start --always-new-process &

We did it 🎉! We decoupled the code that chooses which project to configure and the actual project configuration.

Refactoring: Part II (4 april 2023)

After publishing this article, the WezTerm creator suggested some changes to further simplify the project.lua script by avoiding to manually check if the project file exists:

~/.config/wezterm/project.lua
local startup = function(env_var, projects_module, wezterm)
    local project = os.getenv(env_var)
 
    if project == nil then return end
 
    local status, project_module = pcall(function()
        return require(projects_module .. "." .. project)
        -- lua will automatically convert `projects.<project>` into `~/.config/wezterm/projects/<project>.lua`
        -- when processing this require statement
    end)
    if not status then
        -- in the failure case, project_module is the error message
        wezterm.log_error("Unable to import " .. project_module)
        return
    end
    local project_startup = project_module.startup
 
    if project_startup == nil or type(project_startup) ~= "function" then
        wezterm.log_error("Project " ..
            project .. " has no exported 'startup' function (type is " .. type(project_startup) .. ")")
        return
    end
 
    project_startup(wezterm)
end
 
return {
    startup = startup
}

There is a minor change to do for wezterm.lua as well:

~/.config/wezterm/wezterm.lua
local project = require "project"
 
wezterm.on('gui-startup', function()
    -- we no longer need to pass the full path, just the module name
    project.startup("WZ_PROJECT", "projects", wezterm)
end)

The last change is to how we set the WZ_PROJECT environment variable because we no longer have to pass the .lua file extension:

WZ_PROJECT=website wezterm start --always-new-process &

Source code

Here you can find the files we modified/created:

Bonus: wzp

To improve usability I created a bash script, wzp (WezTerm project), that let you choose one of your project and then starts it automatically (using the same command we used before).

Conclusion

It was a long and sometimes tedious journey, but it was fun and I learned a lot! At the end of the day, I found an amazing new terminal emulator, WezTerm, and I could automate projects configurations just like I needed for my workflow.