My Wezterm Setup
This is my Wezterm setup. My goal was to allow Wezterm to replace tmux, as I wanted a way to essentially have the features I use most from tmux always available — pane splitting, tab creation, and session management. My keybindings mimic those I used in tmux for creating new panes and tabs.
I'd previously used tmux-resurrect to manage my tmux sessions, and relied on its ability to save state periodically. For wezterm, I'm using resurrect.wezterm, with a number of configurations based on my own usage, and I use the same keybindings for naming sessions, saving sessions, and loading sessions, as I did in tmux.
In my own setup, the smart-splits and resurrect configurations are in separate subdirectories; I've adapted the gist to a flat file structure.
1 | -- From https://alexplescan.com/posts/2024/08/10/wezterm/ |
2 | local wezterm = require 'wezterm' |
3 | |
4 | local module = {} |
5 | |
6 | -- Returns a bool based on whether the host operating system's |
7 | -- appearance is light or dark. |
8 | function module.is_dark() |
9 | -- wezterm.gui is not always available, depending on what |
10 | -- environment wezterm is operating in. Just return true |
11 | -- if it's not defined. |
12 | if wezterm.gui then |
13 | -- Some systems report appearance like "Dark High Contrast" |
14 | -- so let's just look for the string "Dark" and if we find |
15 | -- it assume appearance is dark. |
16 | return wezterm.gui.get_appearance():find("Dark") |
17 | end |
18 | return true |
19 | end |
20 | |
21 | return module |
1 | -- Provide generalized functionality for merging tables |
2 | |
3 | local merge = {} |
4 | |
5 | function merge.all(base, overrides) |
6 | local ret = base or {} |
7 | local second = overrides or {} |
8 | for _, v in pairs(second) do table.insert(ret, v) end |
9 | return ret |
10 | end |
11 | |
12 | return merge |
1 | local wezterm = require 'wezterm' |
2 | local module = {} |
3 | |
4 | local function has_value (tab, val) |
5 | for index, value in ipairs(tab) do -- luacheck: ignore 213 |
6 | if value == val then |
7 | return true |
8 | end |
9 | end |
10 | |
11 | return false |
12 | end |
13 | |
14 | local function notify (subject, msg, urgency) |
15 | local allowed_urgency = { 'low', 'normal', 'critical' } |
16 | urgency = urgency or 'normal' |
17 | if not has_value(allowed_urgency, urgency) then |
18 | urgency = 'normal' |
19 | end |
20 | |
21 | wezterm.run_child_process { |
22 | 'notify-send', |
23 | '-i', |
24 | 'org.wezfurlong.wezterm', |
25 | '-a', |
26 | 'wezterm', |
27 | '-u', |
28 | urgency, |
29 | subject, |
30 | msg |
31 | } |
32 | end |
33 | |
34 | module.send = notify |
35 | |
36 | return module |
1 | -- From https://alexplescan.com/posts/2024/08/10/wezterm/ |
2 | local wezterm = require 'wezterm' |
3 | local appearance = require 'appearance' |
4 | |
5 | local function segments_for_right_status(window) |
6 | return { |
7 | window:active_workspace() .. ' ', |
8 | wezterm.strftime('%a %Y-%m-%d %H:%M '), |
9 | wezterm.hostname(), |
10 | } |
11 | end |
12 | |
13 | wezterm.on('update-status', function(window, _) |
14 | local SOLID_LEFT_ARROW = utf8.char(0xe0b2) |
15 | local segments = segments_for_right_status(window) |
16 | |
17 | local color_scheme = window:effective_config().resolved_palette |
18 | -- Note the use of wezterm.color.parse here, this returns |
19 | -- a Color object, which comes with functionality for lightening |
20 | -- or darkening the colour (amongst other things). |
21 | local bg = wezterm.color.parse(color_scheme.background) |
22 | local fg = color_scheme.foreground |
23 | |
24 | -- Each powerline segment is going to be coloured progressively |
25 | -- darker/lighter depending on whether we're on a dark/light colour |
26 | -- scheme. Let's establish the "from" and "to" bounds of our gradient. |
27 | local gradient_to, gradient_from = bg |
28 | |
29 | if appearance.is_dark() then |
30 | gradient_from = gradient_to:lighten(0.2) |
31 | else |
32 | gradient_from = gradient_to:darken(0.2) |
33 | end |
34 | |
35 | -- Yes, WezTerm supports creating gradients, because why not?! Although |
36 | -- they'd usually be used for setting high fidelity gradients on your terminal's |
37 | -- background, we'll use them here to give us a sample of the powerline segment |
38 | -- colours we need. |
39 | local gradient = wezterm.color.gradient( |
40 | { |
41 | orientation = 'Horizontal', |
42 | colors = { gradient_from, gradient_to }, |
43 | }, |
44 | #segments -- only gives us as many colours as we have segments. |
45 | ) |
46 | |
47 | -- We'll build up the elements to send to wezterm.format in this table. |
48 | local elements = {} |
49 | |
50 | for i, seg in ipairs(segments) do |
51 | local is_first = i == 1 |
52 | |
53 | if is_first then |
54 | table.insert(elements, { Background = { Color = 'none' } }) |
55 | end |
56 | table.insert(elements, { Foreground = { Color = gradient[i] } }) |
57 | table.insert(elements, { Text = SOLID_LEFT_ARROW }) |
58 | |
59 | table.insert(elements, { Foreground = { Color = fg } }) |
60 | table.insert(elements, { Background = { Color = gradient[i] } }) |
61 | table.insert(elements, { Text = ' ' .. seg .. ' ' }) |
62 | end |
63 | |
64 | window:set_right_status(wezterm.format(elements)) |
65 | end) |
1 | -- resurrect.wezterm configuration and settings |
2 | -- |
3 | -- This module: |
4 | -- * Configures the resurrect.wezterm plugin |
5 | -- * Configures event listener configuration (via an additional required file) |
6 | -- * Returns wezterm keybinding configuration for resurrect-related actions. |
7 | -- |
8 | -- The main wezterm configuration is then responsible for merging the |
9 | -- keybindings with other keybindings, or setting up its own. |
10 | |
11 | local config = {} |
12 | local wezterm = require 'wezterm' |
13 | local resurrect = wezterm.plugin.require("https://github.com/MLFlexer/resurrect.wezterm") |
14 | |
15 | -- resurrect.wezterm encryption |
16 | -- Uncomment the following to use encryption. |
17 | -- If you do, ensure you have the age tool installed, you have created an |
18 | -- encryption key at ~/.config/age/wezterm-resurrect.txt, and that you supply |
19 | -- the associated public_key below |
20 | resurrect.set_encryption({ |
21 | enable = true, |
22 | method = "age", |
23 | private_key = wezterm.home_dir .. "/.config/age/wezterm-resurrect.txt", |
24 | public_key = "PUBLIC-KEY-GOES-HERE", |
25 | }) |
26 | |
27 | -- resurrect.wezterm periodic save every 5 minutes |
28 | resurrect.periodic_save({ |
29 | interval_seconds = 300, |
30 | save_tabs = true, |
31 | save_windows = true, |
32 | save_workspaces = true, |
33 | }) |
34 | |
35 | -- Save only 5000 lines per pane |
36 | resurrect.set_max_nlines(5000) |
37 | |
38 | -- Default keybindings |
39 | -- These will need to be merged with the main wezterm keys. |
40 | config.keys = { |
41 | { |
42 | -- Save current and window state |
43 | -- See https://github.com/MLFlexer/resurrect.wezterm for options around |
44 | -- saving workspace and window state separately |
45 | key = 'S', |
46 | mods = 'LEADER|SHIFT', |
47 | action = wezterm.action_callback(function(win, pane) -- luacheck: ignore 212 |
48 | local state = resurrect.workspace_state.get_workspace_state() |
49 | resurrect.save_state(state) |
50 | resurrect.window_state.save_window_action() |
51 | end), |
52 | }, |
53 | { |
54 | -- Load workspace or window state, using a fuzzy finder |
55 | key = 'L', |
56 | mods = 'LEADER|SHIFT', |
57 | action = wezterm.action_callback(function(win, pane) |
58 | resurrect.fuzzy_load(win, pane, function(id, label) -- luacheck: ignore 212 |
59 | local type = string.match(id, "^([^/]+)") -- match before '/' |
60 | id = string.match(id, "([^/]+)$") -- match after '/' |
61 | id = string.match(id, "(.+)%..+$") -- remove file extension |
62 | |
63 | local opts = { |
64 | window = win:mux_window(), |
65 | relative = true, |
66 | restore_text = true, |
67 | on_pane_restore = resurrect.tab_state.default_on_pane_restore, |
68 | } |
69 | |
70 | if type == "workspace" then |
71 | local state = resurrect.load_state(id, "workspace") |
72 | resurrect.workspace_state.restore_workspace(state, opts) |
73 | elseif type == "window" then |
74 | local state = resurrect.load_state(id, "window") |
75 | -- opts.tab = win:active_tab() |
76 | resurrect.window_state.restore_window(pane:window(), state, opts) |
77 | elseif type == "tab" then |
78 | local state = resurrect.load_state(id, "tab") |
79 | resurrect.tab_state.restore_tab(pane:tab(), state, opts) |
80 | end |
81 | end) |
82 | end), |
83 | }, |
84 | { |
85 | -- Delete a saved session using a fuzzy finder |
86 | key = 'd', |
87 | mods = 'LEADER|SHIFT', |
88 | action = wezterm.action_callback(function(win, pane) |
89 | resurrect.fuzzy_load( |
90 | win, |
91 | pane, |
92 | function(id) |
93 | resurrect.delete_state(id) |
94 | end, |
95 | { |
96 | title = 'Delete State', |
97 | description = 'Select session to delete and press Enter = accept, Esc = cancel, / = filter', |
98 | fuzzy_description = 'Search session to delete: ', |
99 | is_fuzzy = true, |
100 | } |
101 | ) |
102 | end), |
103 | } |
104 | } |
105 | |
106 | require 'resurrect-events' |
107 | |
108 | return config |
1 | -- resurrect.wezterm event listener configuration |
2 | -- |
3 | -- This module configures event listeners for the resurrect.wezterm plugin. |
4 | |
5 | local wezterm = require 'wezterm' |
6 | local notify = require '../notify' |
7 | local suppress_notification = false |
8 | |
9 | wezterm.on('resurrect.error', function (error) |
10 | notify.send("Wezterm - ERROR", error, 'critical') |
11 | end) |
12 | |
13 | wezterm.on('resurrect.periodic_save', function () |
14 | suppress_notification = true |
15 | end) |
16 | |
17 | wezterm.on('resurrect.save_state.finished', function (session_path) |
18 | local is_workspace_save = session_path:find("state/workspace") |
19 | |
20 | if is_workspace_save == nil then |
21 | return |
22 | end |
23 | |
24 | if suppress_notification then |
25 | suppress_notification = false |
26 | return |
27 | end |
28 | |
29 | local path = session_path:match(".+/([^+]+)$") |
30 | local name = path:match("^(.+)%.json$") |
31 | notify.send("Wezterm - Save workspace", 'Saved workspace ' .. name .. "\n\n" .. session_path) |
32 | end) |
33 | |
34 | wezterm.on('resurrect.load_state.finished', function(name, type) |
35 | local msg = 'Completed loading ' .. type .. ' state: ' .. name |
36 | notify.send("Wezterm - Restore session", msg) |
37 | end) |
1 | local w = require('wezterm') |
2 | |
3 | -- if you are *NOT* lazy-loading smart-splits.nvim (recommended) |
4 | local function is_vim(pane) |
5 | -- this is set by the plugin, and unset on ExitPre in Neovim |
6 | return pane:get_user_vars().IS_NVIM == 'true' |
7 | end |
8 | |
9 | local direction_keys = { |
10 | h = 'Left', |
11 | j = 'Down', |
12 | k = 'Up', |
13 | l = 'Right', |
14 | } |
15 | |
16 | local function split_nav(resize_or_move, key) |
17 | return { |
18 | key = key, |
19 | mods = resize_or_move == 'resize' and 'META' or 'CTRL', |
20 | action = w.action_callback(function(win, pane) |
21 | if is_vim(pane) then |
22 | -- pass the keys through to vim/nvim |
23 | win:perform_action({ |
24 | SendKey = { key = key, mods = resize_or_move == 'resize' and 'META' or 'CTRL' }, |
25 | }, pane) |
26 | else |
27 | if resize_or_move == 'resize' then |
28 | win:perform_action({ AdjustPaneSize = { direction_keys[key], 3 } }, pane) |
29 | else |
30 | win:perform_action({ ActivatePaneDirection = direction_keys[key] }, pane) |
31 | end |
32 | end |
33 | end), |
34 | } |
35 | end |
36 | |
37 | return { |
38 | keys = { |
39 | -- move between split panes |
40 | split_nav('move', 'h'), |
41 | split_nav('move', 'j'), |
42 | split_nav('move', 'k'), |
43 | split_nav('move', 'l'), |
44 | |
45 | -- resize panes |
46 | split_nav('resize', 'h'), |
47 | split_nav('resize', 'j'), |
48 | split_nav('resize', 'k'), |
49 | split_nav('resize', 'l'), |
50 | }, |
51 | } |
1 | local wezterm = require 'wezterm' |
2 | |
3 | wezterm.on( |
4 | 'format-tab-title', |
5 | function(tab, tabs, panes, config, hover, max_width) |
6 | if tab.is_active then |
7 | -- Do nothing; normal active style is fine, so just return the text |
8 | return tab.active_pane.title |
9 | end |
10 | |
11 | local has_unseen_output = false |
12 | |
13 | for _, pane in ipairs(tab.panes) do |
14 | if pane.has_unseen_output then |
15 | has_unseen_output = true |
16 | break |
17 | end |
18 | end |
19 | |
20 | if has_unseen_output then |
21 | -- Set the background to Solarized's yellow, and foreground to |
22 | -- Solarized's base02 |
23 | return { |
24 | { Background = { Color = '#b58900' } }, |
25 | { Foreground = { Color = '#073642' } }, |
26 | { Text = ' ' .. tab.active_pane.title .. ' ' }, |
27 | } |
28 | end |
29 | |
30 | -- Do nothing different, as there's no activity; just return the text |
31 | return tab.active_pane.title |
32 | end |
33 | ) |
1 | -- Pull in the wezterm API, some of its modules, and plugins |
2 | local wezterm = require 'wezterm' |
3 | local act = wezterm.action |
4 | local merge = require 'merge' |
5 | local mux = wezterm.mux |
6 | local resurrect = require 'resurrect-config' |
7 | local smart_splits = require 'smart-splits-setup' |
8 | |
9 | -- -------------------------------------------------------------------- |
10 | -- CONFIGURATION |
11 | -- -------------------------------------------------------------------- |
12 | |
13 | -- This table will hold the configuration. |
14 | local config = {} |
15 | |
16 | -- In newer versions of wezterm, use the config_builder which will |
17 | -- help provide clearer error messages |
18 | if wezterm.config_builder then |
19 | config = wezterm.config_builder() |
20 | end |
21 | |
22 | config.adjust_window_size_when_changing_font_size = false |
23 | config.automatically_reload_config = true |
24 | config.color_scheme = 'Solarized (dark) (terminal.sexy)' |
25 | config.enable_scroll_bar = true |
26 | config.enable_wayland = true |
27 | -- config.font = wezterm.font('Hack') |
28 | config.font = wezterm.font('MonaspiceNe NFP') |
29 | config.font_size = 12.0 |
30 | config.hide_tab_bar_if_only_one_tab = false |
31 | -- The leader is similar to how tmux defines a set of keys to hit in order to |
32 | -- invoke tmux bindings. Binding to ctrl-a here to mimic tmux |
33 | config.leader = { key = 'a', mods = 'CTRL', timeout_milliseconds = 2000 } |
34 | config.mouse_bindings = { |
35 | -- Open URLs with Ctrl+Click |
36 | { |
37 | event = { Up = { streak = 1, button = 'Left' } }, |
38 | mods = 'CTRL', |
39 | action = act.OpenLinkAtMouseCursor, |
40 | } |
41 | } |
42 | config.pane_focus_follows_mouse = true |
43 | config.scrollback_lines = 5000 |
44 | config.tiling_desktop_environments = { |
45 | 'Wayland', |
46 | } |
47 | config.use_dead_keys = false |
48 | config.warn_about_missing_glyphs = false |
49 | config.window_decorations = "TITLE | RESIZE" |
50 | config.window_padding = { |
51 | left = 0, |
52 | right = 0, |
53 | top = 0, |
54 | bottom = 0, |
55 | } |
56 | |
57 | -- Tab bar |
58 | config.use_fancy_tab_bar = true |
59 | config.tab_bar_at_bottom = true |
60 | config.switch_to_last_active_tab_when_closing_tab = true |
61 | config.tab_max_width = 32 |
62 | config.colors = { |
63 | quick_select_label_bg = { Color = '#fdf6e3' }, |
64 | quick_select_label_fg = { Color = '#073642' }, |
65 | tab_bar = { |
66 | active_tab = { |
67 | fg_color = '#073642', |
68 | bg_color = '#2aa198', |
69 | } |
70 | } |
71 | } |
72 | |
73 | -- Add items to launch menu |
74 | config.launch_menu = { |
75 | { |
76 | label = 'mwop', |
77 | cwd = wezterm.home_dir .. '/git/weierophinney/mwop.net', |
78 | }, |
79 | { |
80 | label = 'onedrive', |
81 | cwd = wezterm.home_dir .. '/OneDrive', |
82 | }, |
83 | { |
84 | label = 'top', |
85 | args = { 'top' }, |
86 | }, |
87 | } |
88 | |
89 | -- Custom key bindings |
90 | config.keys = { |
91 | -- Show the launcher |
92 | { |
93 | key = 'm', |
94 | mods = 'LEADER', |
95 | action = act.ShowLauncher, |
96 | }, |
97 | |
98 | -- Copy mode |
99 | { |
100 | key = '[', |
101 | mods = 'LEADER', |
102 | action = act.ActivateCopyMode, |
103 | }, |
104 | |
105 | -- ---------------------------------------------------------------- |
106 | -- TABS |
107 | -- |
108 | -- Where possible, I'm using the same combinations as I would in tmux |
109 | -- ---------------------------------------------------------------- |
110 | |
111 | -- Show tab navigator; similar to listing panes in tmux |
112 | { |
113 | key = 'w', |
114 | mods = 'LEADER', |
115 | action = act.ShowTabNavigator, |
116 | }, |
117 | |
118 | -- Create a tab (alternative to Ctrl-Shift-Tab) |
119 | { |
120 | key = 'c', |
121 | mods = 'LEADER', |
122 | action = act.SpawnTab 'CurrentPaneDomain', |
123 | }, |
124 | |
125 | -- Rename current tab; analagous to command in tmux |
126 | { |
127 | key = ',', |
128 | mods = 'LEADER', |
129 | action = act.PromptInputLine { |
130 | description = 'Enter new name for tab', |
131 | action = wezterm.action_callback( |
132 | function(window, pane, line) -- luacheck: ignore 212 |
133 | if line then |
134 | window:active_tab():set_title(line) |
135 | end |
136 | end |
137 | ), |
138 | }, |
139 | }, |
140 | |
141 | -- Move to next/previous TAB |
142 | { |
143 | key = 'n', |
144 | mods = 'LEADER', |
145 | action = act.ActivateTabRelative(1), |
146 | }, |
147 | { |
148 | key = 'p', |
149 | mods = 'LEADER', |
150 | action = act.ActivateTabRelative(-1), |
151 | }, |
152 | |
153 | -- Close tab |
154 | { |
155 | key = '&', |
156 | mods = 'LEADER|SHIFT', |
157 | action = act.CloseCurrentTab{ confirm = true }, |
158 | }, |
159 | |
160 | -- ---------------------------------------------------------------- |
161 | -- PANES |
162 | -- |
163 | -- These are great and get me most of the way to replacing tmux |
164 | -- entirely, particularly as you can use "wezterm ssh" to ssh to another |
165 | -- server, and still retain Wezterm as your terminal there. |
166 | -- |
167 | -- Note that these only define creating splits, relative motion |
168 | -- (next/previous), zooming, swapping, and killing panes; actual directional |
169 | -- motions between panes or resizing them are handled by smart splits. |
170 | -- ---------------------------------------------------------------- |
171 | |
172 | -- Vertical split |
173 | { |
174 | -- | |
175 | key = '|', |
176 | mods = 'LEADER|SHIFT', |
177 | action = act.SplitPane { |
178 | direction = 'Right', |
179 | size = { Percent = 50 }, |
180 | }, |
181 | }, |
182 | |
183 | -- Horizontal split |
184 | { |
185 | -- - |
186 | key = '-', |
187 | mods = 'LEADER', |
188 | action = act.SplitPane { |
189 | direction = 'Down', |
190 | size = { Percent = 50 }, |
191 | }, |
192 | }, |
193 | |
194 | -- Close/kill active pane |
195 | { |
196 | key = 'x', |
197 | mods = 'LEADER', |
198 | action = act.CloseCurrentPane { confirm = true }, |
199 | }, |
200 | |
201 | -- Swap active pane with another one |
202 | { |
203 | key = '{', |
204 | mods = 'LEADER|SHIFT', |
205 | action = act.PaneSelect { mode = "SwapWithActiveKeepFocus" }, |
206 | }, |
207 | |
208 | -- Zoom current pane (toggle) |
209 | { |
210 | key = 'z', |
211 | mods = 'LEADER', |
212 | action = act.TogglePaneZoomState, |
213 | }, |
214 | { |
215 | key = 'f', |
216 | mods = 'ALT', |
217 | action = act.TogglePaneZoomState, |
218 | }, |
219 | |
220 | -- Move to next/previous pane |
221 | { |
222 | key = ';', |
223 | mods = 'LEADER', |
224 | action = act.ActivatePaneDirection('Prev'), |
225 | }, |
226 | { |
227 | key = 'o', |
228 | mods = 'LEADER', |
229 | action = act.ActivatePaneDirection('Next'), |
230 | }, |
231 | |
232 | -- ---------------------------------------------------------------- |
233 | -- Workspaces |
234 | -- |
235 | -- These are roughly equivalent to tmux sessions. |
236 | -- ---------------------------------------------------------------- |
237 | |
238 | -- Attach to muxer |
239 | { |
240 | key = 'a', |
241 | mods = 'LEADER', |
242 | action = act.AttachDomain 'unix', |
243 | }, |
244 | |
245 | -- Detach from muxer |
246 | { |
247 | key = 'd', |
248 | mods = 'LEADER', |
249 | action = act.DetachDomain { DomainName = 'unix' }, |
250 | }, |
251 | |
252 | -- Show list of workspaces |
253 | { |
254 | key = 's', |
255 | mods = 'LEADER', |
256 | action = act.ShowLauncherArgs { flags = 'WORKSPACES' }, |
257 | }, |
258 | |
259 | -- Rename current session; analagous to command in tmux |
260 | { |
261 | key = '$', |
262 | mods = 'LEADER|SHIFT', |
263 | action = act.PromptInputLine { |
264 | description = 'Enter new name for session', |
265 | action = wezterm.action_callback( |
266 | function(window, pane, line) -- luacheck: ignore 212 |
267 | if line then |
268 | mux.rename_workspace( |
269 | window:mux_window():get_workspace(), |
270 | line |
271 | ) |
272 | end |
273 | end |
274 | ), |
275 | }, |
276 | }, |
277 | } |
278 | |
279 | -- -------------------------------------------------------------------- |
280 | -- Smart splits |
281 | -- |
282 | -- See https://github.com/mrjones2014/smart-splits.nvim |
283 | -- |
284 | -- Allows moving and resizing panes easily, as well as navigation between |
285 | -- wezterm and nvim panes |
286 | -- -------------------------------------------------------------------- |
287 | config.keys = merge.all(config.keys, smart_splits.keys) |
288 | |
289 | -- -------------------------------------------------------------------- |
290 | -- resurrect.wezterm |
291 | -- |
292 | -- See https://github.com/MLFlexer/resurrect.wezterm |
293 | -- See resurrect.lua |
294 | -- -------------------------------------------------------------------- |
295 | config.keys = merge.all(config.keys, resurrect.keys) |
296 | |
297 | -- Powerline for tab bar |
298 | require 'powerline' |
299 | |
300 | -- Tab status |
301 | require 'tab-status' |
302 | |
303 | -- Plugin management |
304 | -- Automatically update plugins |
305 | -- wezterm.plugin.update_all() |
306 | |
307 | -- and finally, return the configuration to wezterm |
308 | return config |