Automate terminal configurations with WezTerm
3 April 2023
9 min read
Table of Contents
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:
#!/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:
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:

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:
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!

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.
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:
- As projects configurations increase, readability decreases
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
:
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:
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
:
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:
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:
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.