Using resurrect.wezterm to manage Wezterm session state

One of my goals when adopting Wezterm was to replace tmux. To do that, I needed not just the ability to open additional tabs/windows and to split into panes, but also a feature I'd come to rely on heavily in the tmux ecosystem: session saving and restoration, which I accomplished with the tmux-resurrect plugin.

I tried a number of options, but was eventually pointed to resurrect.wezterm.

In this post, I'll detail how I've configured it, as well as a workflow I've developed for interacting with it that gives me (a) reasonable satisfaction that I won't lose work, and (b) additional flexibility for branching off work.

A note on sharing sessions

What this solution does not do is allow me to share a Wezterm session, or open it simultaneously in another wezterm session. For that, you need a Unix muxer session, and resurrect.wezterm does not play well with it. As such, you will need to do any such sessions without wezterm.resurrect.

Configuration

I've been finding it's most maintainable to throw my configuration for specific plugins or features into their own modules whenever possible. As such, I started by creating a resurrect/config.lua file that would do the following:

  • Setup encryption for the saved state files
  • Setup periodic saving, so I don't have to remember to save or worry about what happens if there's a crash before I save.
  • Setup the maximum number of lines per pane to save.
  • Return the default keybinding I want to use with the module.

That file ends up looking like the following:

-- File: resurrect/config.lua
-- resurrect.wezterm configuration and settings
--
-- This module:
-- * Configures the resurrect.wezterm plugin
-- * Configures event listener configuration (via an additional required file)
-- * Returns wezterm keybinding configuration for resurrect-related actions.
--
-- The main wezterm configuration is then responsible for merging the
-- keybindings with other keybindings, or setting up its own.

local config    = {}
local wezterm   = require 'wezterm'
local resurrect = wezterm.plugin.require("https://github.com/MLFlexer/resurrect.wezterm")

-- resurrect.wezterm encryption
-- Uncomment the following to use encryption.
-- If you do, ensure you have the age tool installed, you have created an
-- encryption key at ~/.config/age/wezterm-resurrect.txt, and that you supply
-- the associated public_key below
resurrect.set_encryption({
    enable      = true,
    method      = "age",
    private_key = wezterm.home_dir .. "/.config/age/wezterm-resurrect.txt",
    public_key  = "THE-PUBLIC-KEY-VALUE-GOES-HERE",
})

-- resurrect.wezterm periodic save every 5 minutes
resurrect.periodic_save({
    interval_seconds = 300,
    save_tabs = true,
    save_windows = true,
    save_workspaces = true,
})

-- Save only 5000 lines per pane
resurrect.set_max_nlines(5000)

-- Default keybindings
-- These will need to be merged with the main wezterm keys.
config.keys = {
    {
        -- Save current and window state
        -- See https://github.com/MLFlexer/resurrect.wezterm for options around
        -- saving workspace and window state separately
        key = 'S',
        mods = 'LEADER|SHIFT',
        action = wezterm.action_callback(function(win, pane) -- luacheck: ignore 212
            local state = resurrect.workspace_state.get_workspace_state()
            resurrect.save_state(state)
            resurrect.window_state.save_window_action()
        end),
    },
    {
        -- Load workspace or window state, using a fuzzy finder
        key = 'L',
        mods = 'LEADER|SHIFT',
        action = wezterm.action_callback(function(win, pane)
            resurrect.fuzzy_load(win, pane, function(id, label) -- luacheck: ignore 212
                local type = string.match(id, "^([^/]+)") -- match before '/'
                id         = string.match(id, "([^/]+)$") -- match after '/'
                id         = string.match(id, "(.+)%..+$") -- remove file extension

                local opts = {
                    window          = win:mux_window(),
                    relative        = true,
                    restore_text    = true,
                    on_pane_restore = resurrect.tab_state.default_on_pane_restore,
                }

                if type == "workspace" then
                    local state = resurrect.load_state(id, "workspace")
                    resurrect.workspace_state.restore_workspace(state, opts)
                elseif type == "window" then
                    local state = resurrect.load_state(id, "window")
                    -- opts.tab = win:active_tab()
                    resurrect.window_state.restore_window(pane:window(), state, opts)
                elseif type == "tab" then
                    local state = resurrect.load_state(id, "tab")
                    resurrect.tab_state.restore_tab(pane:tab(), state, opts)
                end
            end)
        end),
    },
    {
        -- Delete a saved session using a fuzzy finder
        key = 'd',
        mods = 'LEADER|SHIFT',
        action = wezterm.action_callback(function(win, pane)
            resurrect.fuzzy_load(
                win,
                pane,
                function(id)
                    resurrect.delete_state(id)
                end,
                {
                    title             = 'Delete State',
                    description       = 'Select session to delete and press Enter = accept, Esc = cancel, / = filter',
                    fuzzy_description = 'Search session to delete: ',
                    is_fuzzy          = true,
                }
            )
        end),
    }
}

require 'resurrect/events'

return config

I've setup keybindings that mimic what I had in tmux:

  • Leader-S will save the current workspace session
  • Leader-L will give me a way to select a workspace session to load
  • Leader-D will give me a way to select a workspace session to delete

(I map Leader to Ctrl-a, which is a common convention in both screen and tmux, so I don't lose any muscle memory here.)

In my main wezterm.lua file, I then make use of my merge.all function to merge the returned keybindings configuration:

local resurrect = require 'resurrect/config'
config.keys = merge.all(config.keys, resurrect.keys)

But what is that require 'resurrect/events' line for?

Listening to resurrect events

When I save a session manually or load a session, I'd like some notification that the operation was successful. resurrect.wezterm emits a number of events for different workflow states, and you can tie into those. I did exactly that, and had my handlers use my notify.send module to emit the notifications:

-- File: resurrect/events.lua
-- resurrect.wezterm event listener configuration
--
-- This module configures event listeners for the resurrect.wezterm plugin.

local wezterm               = require 'wezterm'
local notify                = require '../notify'
local suppress_notification = false

wezterm.on('resurrect.error', function (error)
    notify.send("Wezterm - ERROR", error, 'critical')
end)

wezterm.on('resurrect.periodic_save', function ()
    suppress_notification = true
end)

wezterm.on('resurrect.save_state.finished', function (session_path)
    local is_workspace_save = session_path:find("state/workspace")

    if is_workspace_save == nil then
        return
    end

    if suppress_notification then
        suppress_notification = false
        return
    end

    local path = session_path:match(".+/([^+]+)$")
    local name = path:match("^(.+)%.json$")
    notify.send("Wezterm - Save workspace", 'Saved workspace ' .. name .. "\n\n" .. session_path)
end)

wezterm.on('resurrect.load_state.finished', function(name, type)
    local msg  = 'Completed loading ' .. type .. ' state: ' .. name
    notify.send("Wezterm - Restore session", msg)
end)

If you follow the write-up in the resurrect.wezterm README file, you'll note that my approach is a bit different, particularly when it comes to tracking a periodic save. Why? Well, when a periodic save happens, it saves the window, the workspace, and all tabs, but each of those triggers separately, and each triggers a resurrect.save_state.finished event on completion. As a result, the periodic_save state often gets reset by one of these events, which causes another event to flow through. The upshot is that if I followed the documented example, I was still seeing notifications each time periodic_save would trigger. Since I'm really only concerned about workspace save events, I've modified the logic to only trigger if it's a workspace save, which I can identify by searching for the string "state/workspace" in the session filename passed to the callback. (It's not perfect, but it's good enough for my needs.)

From there, I can do the normal logic around identifying if I'm within a periodic_save event.

I've also done some logic so I can see the name of the session at a glance, as well as see the full path to the file (for debugging purposes).

One final thing to note is my handling of the resurrect.error event. I use the "critical" urgency flag to my notify.send functionality here so that the notification requires manual dismissal. Doing this ensures I do not miss these errors!

Day to day usage

The periodic_save settings mean that as I'm working, Wezterm is periodically saving my open sessions, meaning I never have to lose my place. But how do I restore?

The interesting thing about resurrect.wezterm is that when you load a session, it does not change the current workspace name. This means that if you start in the "default" workspace, and then load your "project" workspace, you're still in the "default" workspace, and that's where periodic_save will save the workspace contents.

This may sound bad, but I've found in practice that it's actually a huge benefit. It allows me to "fork" my workspace state. I can name the workspace something different, and its state will diverge... which allows me to go back to the original state if I want to later.

So, my workflow has become:

  • Change the name of the current workspace workspace to what I want it to be saved as eventually; this may be the name of a previously saved session!
  • If I want to start from a previous saved session, load the saved workspace.
  • Manually save only when I am planning to close a terminal window or reboot, but otherwise have periodic_save handle saving state.

I find this to be an improvement over tmux-resurrect!

Final thoughts

I've tried a few different approaches to saving sessions since adopting Wezterm, but this is the one that has finally given me all the features — and then some! — that I had in tmux-resurrect. While being able to load and mirror an existing session would sometimes be useful, it's a niche feature for me; I never do XP-style programming anymore, and I rarely find myself in a position where I want or need to load a current session into another window. However, I've often wished I could go back in time to a previous state, and this set of tools gives me that.