Workspace Manager for Wezterm

April 24, 2026 · View on GitHub

Navigate projects effortlessly with smart workspace switching and keyboard-driven navigation

https://github.com/user-attachments/assets/046a0b6b-56ca-46be-8e72-1fb199330da9

A powerful workspace management plugin for Wezterm featuring:

  • Unified workspace switcher with fuzzy search, recency sorting, and in-overlay actions
  • Keyboard navigation (cycle, toggle, quick switch)
  • Zoxide integration for directory history
  • Built-in session persistence: workspaces survive WezTerm restarts with full layout restoration

Motivation

WezTerm workspaces are powerful but require manual setup. There's no built-in switcher UI, no recency tracking, and no session persistence. Existing plugins address pieces of this but require separate configuration and wiring. workspace-manager combines workspace switching, lifecycle management, and session persistence into a single plugin with one apply_to_config() call.

Design

Switcher entry types

The switcher presents three categories of entries:

  • Live workspaces: currently running in memory. Switching is instant; no restore needed.
  • Saved workspaces: a workspace that has state on disk but isn't currently running. This happens when WezTerm exits (or crashes) before you explicitly deleted the workspace. State is written to disk when you switch away, on a periodic timer, or via a manual save. These appear in the switcher when session_enabled = true so you can pick up where you left off. Selecting one spawns a new workspace and restores its full layout. Deleting a workspace via Ctrl+D removes both the live workspace and its state file, so it won't reappear.
  • Suggestions: directories from zoxide history by default, or a custom get_choices provider. Not workspaces yet. Selecting one creates a new workspace at that path. Set get_choices = false to disable suggestions entirely.

Session lifecycle

State is saved to disk automatically when you switch away from a workspace, on a periodic timer (every 10 minutes by default), and manually by binding save_workspace() to a key. A "saved" workspace is just a JSON state file on disk with no process running. State files can accumulate over time: quitting WezTerm, a crash, or a periodic save all write state that persists until you explicitly delete the workspace via Ctrl+D in the switcher (which removes both the running workspace and its state file) or until session_exclude_workspaces is set to prevent saves.

Path normalization

All workspace names are normalized to use ~ for the home directory. This prevents duplicates when the same path is referenced as ~/Code/foo and /Users/you/Code/foo, since they resolve to the same workspace name.

Installation

local wezterm = require("wezterm")
local config = wezterm.config_builder()

-- Load the plugin
local workspace_manager = wezterm.plugin.require("https://github.com/ryanmsnyder/workspace-manager.wezterm")

-- Apply to config (adds default keybindings)
workspace_manager.apply_to_config(config)

return config

Requirements

  • Wezterm (version 20230408-112425-69ae8472 or later for InputSelector)
  • zoxide (optional, used by default for directory history; can be replaced with a custom provider via get_choices)
  • A LEADER key configured in your wezterm config

Keybindings

When using apply_to_config(), the following default keybindings are added:

KeyActionDescription
LEADER + sWorkspace switcherOpen the unified switcher
LEADER + Shift-SPrevious workspaceSwitch to the previously active workspace (Alt-Tab toggle)
CTRL + ]Next workspaceCycle to the next workspace in alphabetical order
CTRL + [Previous workspaceCycle to the previous workspace in alphabetical order

While the switcher is open, the following keys are available:

KeyAction
EnterSwitch to the selected workspace
Ctrl + DDelete the selected workspace (re-opens switcher)
Ctrl + NCreate a new workspace by name
Ctrl + PCreate a new workspace at a path
Ctrl + RRename the selected workspace
EscapeCancel

A keybinding legend is shown in the right status bar while the switcher is open.

Note: Workspace cycling (CTRL+] and CTRL+[) always uses case-insensitive alphabetical ordering for predictable, stable navigation. The switcher uses recency-based sorting by default, configurable via workspace_switcher_sort.

Custom Keybindings

If you prefer to set up your own keybindings instead of using apply_to_config():

local workspace_manager = wezterm.plugin.require("https://github.com/ryanmsnyder/workspace-manager.wezterm")

config.keys = {
  {
    key = "w",
    mods = "CTRL|SHIFT",
    action = workspace_manager.workspace_switcher(),
  },
  {
    key = "p",
    mods = "CTRL|SHIFT",
    action = workspace_manager.switch_to_previous_workspace(),
  },
  {
    key = "]",
    mods = "CTRL",
    action = workspace_manager.next_workspace(),
  },
  {
    key = "[",
    mods = "CTRL",
    action = workspace_manager.previous_workspace(),
  },
}

Note: Even with custom keybindings, you still need to call apply_to_config(config) to register the workspace_switcher_actions key table (which powers the in-switcher Ctrl+D/N/P/R bindings) and the event handlers for session persistence and status bar updates.

API

Configuration

OptionTypeDefaultDescription
wezterm_pathstringAuto-detectedPath to wezterm executable (only needed if auto-detection fails)
zoxide_pathstring"zoxide"Path to zoxide binary (unused when get_choices is set)
get_choicesfunction/falsenilCustom entry provider function (see Custom Choices). Defaults to zoxide when nil. Set to false to disable suggestions entirely.
filter_choicestable/functionnilFilter switcher entries (see Filtering Choices). Table: exact path allowlist. Function: predicate receiving a choice object, return true to keep.
show_current_workspace_in_switcherbooleanfalseShow current workspace in the switcher list
show_current_workspace_hintbooleantrueShow current workspace name in the switcher description bar
start_in_fuzzy_modebooleantrueStart switcher in fuzzy search mode (false for positional shortcuts)
notifications_enabledbooleanfalseEnable toast notifications (requires code-signed wezterm on macOS)
workspace_count_formatstring"compact"Display workspace counts: nil (disabled), "compact" (2w 3t 5p), or "full" (2 wins, 3 tabs, 5 panes)
use_basename_for_workspace_namesbooleanfalseUse directory basename instead of full path (falls back for duplicates)
workspace_switcher_sortstring"recency"Sort order: "recency" (most recent first) or "alphabetical"
switcher_keystablenilOverride in-switcher action key bindings (see Switcher Keys)
show_switcher_hintsbooleantrueShow action key hints in the switcher description bar (both modes). Set to false to hide (use get_switcher_legend() instead)
workspace_iconstring"󱂬 "Icon glyph for workspace entries in the switcher
workspace_icon_currentstringnilIcon glyph for the active workspace (falls back to workspace_icon)
entry_iconstring" "Icon glyph for custom/zoxide entries in the switcher
colorstablenilOverride theme colors (see Styling)

Session persistence options (requires session_enabled = true):

OptionTypeDefaultDescription
session_enabledbooleanfalseEnable automatic workspace state save/restore
session_periodic_save_intervalnumber600Seconds between periodic saves (nil to disable)
session_periodic_save_allbooleanfalsePeriodic save: true = all workspaces, false = active workspace only
session_max_scrollback_linesnumber3500Max scrollback lines to capture per pane
session_exclude_workspacestable{"default"}Workspace names to never save or restore. "default" is excluded by default because it's WezTerm's built-in fallback workspace and rarely worth persisting.
session_state_dirstringnilOverride state directory (default: ~/.local/share/wezterm/workspace_state/)
session_on_pane_restorefunctionnilCustom per-pane restore callback (default: replays processes / injects scrollback)
session_restore_on_startupbooleanfalseAutomatically restore the most recently used workspace when WezTerm starts

Actions

All actions return a WezTerm action that can be used in keybindings:

  • workspace_manager.workspace_switcher(): opens the unified switcher (switch, delete, new, rename all from within)
  • workspace_manager.switch_to_previous_workspace(): switches to the previously active workspace (Alt-Tab toggle behavior)
  • workspace_manager.next_workspace(): cycles to the next workspace in alphabetical order (with wrapping)
  • workspace_manager.previous_workspace(): cycles to the previous workspace in alphabetical order (with wrapping)
  • workspace_manager.save_workspace(): saves the current workspace state to disk (requires session_enabled = true)

Helpers

  • workspace_manager.get_switcher_legend(): returns a formatted string of action key hints for use in set_right_status() (see Status Bar Legend)
  • workspace_manager.get_zoxide_paths(limit?): returns up to limit paths from zoxide history as plain strings, for use inside a get_choices function. Omit limit to return all results. Respects zoxide_path if set.

apply_to_config(config)

Registers event handlers, the workspace_switcher_actions key table, and default keybindings.

Features

Workspace Switcher

The unified switcher (LEADER + s) shows:

  1. Active workspaces (sorted by recency or alphabetically, marked with 󱂬 icon)
  2. Saved workspaces from previous sessions (also marked with 󱂬 icon, requires session_enabled = true)
  3. Zoxide directory history or custom entries from get_choices (marked with icon)

While the switcher is open, additional actions are available via key bindings:

  • Ctrl+D: delete the highlighted workspace. Blocked if you highlight the current workspace. Re-opens the switcher automatically after deleting.
  • Ctrl+N: create a new named workspace. Input is a name; the new workspace opens at the default cwd.
  • Ctrl+P: create a new workspace rooted at a path. Input is a filesystem path; the workspace name is derived from the directory basename.
  • Ctrl+R: rename the highlighted workspace. If the new name matches an existing workspace, windows are merged into it.

Switcher Keys

By default the in-switcher action keys are Ctrl+D (delete), Ctrl+N (new), Ctrl+P (new at path), and Ctrl+R (rename). Override them via switcher_keys:

workspace_manager.switcher_keys = {
  delete      = { key = "x", mods = "CTRL" },  -- remap delete to Ctrl+X
  rename      = false,                           -- disable rename entirely
  -- unspecified actions keep their defaults
}

Actions: "delete", "new", "new_at_path", "rename". Enter (select) and Escape (cancel) are not configurable.

The description bar and get_switcher_legend() both auto-reflect whatever keys are configured. To hide hints from the description bar and show them only in the right-status legend instead:

workspace_manager.show_switcher_hints = false

Status Bar Legend

The plugin does not register update-right-status. Instead, call workspace_manager.get_switcher_legend() from your own handler to render the legend while the switcher is open. The legend text automatically reflects the configured switcher_keys:

wezterm.on("update-right-status", function(window, pane)
  if window:active_key_table() == "workspace_switcher_actions" then
    window:set_right_status(workspace_manager.get_switcher_legend())
    return
  end
  -- your normal right status logic here
end)

get_switcher_legend() returns a formatted string (e.g. ^D=del ^N=new ^P=path ^R=rename Esc=cancel) in the muted color. To render your own legend content, format and set it directly:

wezterm.on("update-right-status", function(window, pane)
  if window:active_key_table() == "workspace_switcher_actions" then
    window:set_right_status(wezterm.format({
      { Foreground = { Color = "#585b70" } },
      { Text = "  ^D=del  ^N=new  ^P=path  ^R=rename  Esc=cancel " },
    }))
    return
  end
  -- your normal right status logic here
end)

Styling

Override any subset of the theme colors via M.colors. All keys accept the same format:

  • A color string (WezTerm AnsiColor name or "#hex"), applied as a foreground color
  • A FormatItem list, for full control over foreground, background, intensity, etc.
-- Color string (foreground only)
workspace_name = "#cdd6f4"

-- FormatItem list (full control)
workspace_name = {
  { Foreground = { Color = "#cdd6f4" } },
  { Attribute = { Intensity = "Bold" } },
}

Prompt colors:

KeyDefaultUsed for
prompt_accent"Lime"Workspace name/path text in prompt descriptions, e.g. the ~/ws in the switcher description and "Renaming: ~/ws"
prompt_headingBoldLabel text surrounding the accent, e.g. "Renaming:", "Directory does not exist:"
muted"#888888"Secondary text: switcher legend and keyboard shortcut hints in description bars

Switcher label segments (each segment of a label can be styled independently per entry category):

The switcher has three entry categories: workspace entries (non-active), the current active workspace, and custom/zoxide entries (not yet workspaces). Each category's segments can be colored independently.

Non-active workspace entries:

KeyDefaultUsed for
workspace_iconnilIcon glyph (terminal default)
workspace_namenilWorkspace name (terminal default)
workspace_countsnilCount suffix, e.g. (2w 3t 5p) (terminal default)

Active (current) workspace, each falls back to the matching workspace_* key:

KeyDefaultUsed for
workspace_icon_currentnilIcon glyph
workspace_name_currentnilWorkspace name
workspace_counts_currentnilCount suffix
workspace_current_markernil (current) text appended to the label, falls back to prompt_accent

Custom/zoxide entries, each falls back to the matching workspace_* key:

KeyDefaultUsed for
entry_iconnilIcon glyph
entry_namenilEntry name
-- Match a Dracula theme
workspace_manager.colors = {
  prompt_accent = "#50fa7b",
  muted = "#6272a4",
}

-- Dim counts with Half intensity (avoids InputSelector selection highlight clash)
workspace_manager.colors = {
  workspace_counts = { { Attribute = { Intensity = "Half" } } },
}

-- Full custom label palette with per-category styling
workspace_manager.colors = {
  prompt_accent            = "#50fa7b",
  workspace_name           = "#f8f8f2",                              -- non-active workspace names
  workspace_counts         = { { Attribute = { Intensity = "Half" } } },
  workspace_name_current   = "#50fa7b",                              -- active workspace name
  workspace_current_marker = "#50fa7b",                              -- "(current)" marker
  entry_name               = "#6272a4",                              -- custom/zoxide entry names
}

Non-fuzzy mode: shortcut label column colors

When start_in_fuzzy_mode = false, each row shows a shortcut key (e.g. 1., 2.). WezTerm lets you color that column via two entries in config.colors (nightly builds only):

config.colors = {
  input_selector_label_bg = { Color = "#1e1e2e" },
  input_selector_label_fg = { Color = "#585b70" },
}

This is a WezTerm-level setting, not a plugin setting. Set it directly in your config.colors table. There is no equivalent for the selected row highlight color, which uses reverse-video and is not configurable.

Session Persistence

workspace-manager includes built-in session persistence. Workspace layouts (panes, tabs, splits, working directories, scrollback) are automatically saved and restored across WezTerm restarts. No external plugins required.

Quick Setup

local workspace_manager = wezterm.plugin.require("https://github.com/ryanmsnyder/workspace-manager.wezterm")

workspace_manager.session_enabled = true  -- enable session persistence
workspace_manager.session_restore_on_startup = true  -- optional: restore last workspace on launch

workspace_manager.apply_to_config(config)

That's it. With session_enabled = true:

  • State is saved automatically when you switch away from a workspace
  • Previously-active workspaces appear in the switcher after restarting WezTerm, sorted by recency/alphabetical alongside live ones
  • State is restored when you select a saved workspace (tabs, pane splits, working directories, scrollback text)
  • Periodic auto-save runs every 10 minutes as a crash safety net
  • Deleting a workspace removes its saved state so it won't reappear

With session_restore_on_startup = true, WezTerm also opens directly into your most recently used workspace on launch instead of the default workspace.

State Location

State files are stored at ~/.local/share/wezterm/workspace_state/<workspace-name>.json.

Path separators in workspace names are encoded as + in filenames. For example, a workspace named ~/Code/myproject is stored as ~+Code+myproject.json.

Custom Layouts

State files are plain JSON, which means you can create them by hand to define pre-defined workspace layouts. Drop a .json file into the state directory and it will appear as a saved workspace in the switcher the next time it opens.

{
  "window_states": [
    {
      "is_focused": true,
      "size": {
        "cols": 200,
        "dpi": 144,
        "pixel_height": 1600,
        "pixel_width": 2560,
        "rows": 50
      },
      "tabs": [
        {
          "is_active": true,
          "is_zoomed": false,
          "pane_tree": {
            "cwd": "/Users/you/Code/myproject/",
            "domain": "local",
            "height": 50,
            "index": 0,
            "is_active": true,
            "is_zoomed": false,
            "left": 0,
            "pixel_height": 1600,
            "pixel_width": 2560,
            "top": 0,
            "width": 200
          },
          "title": "main"
        }
      ]
    }
  ],
  "workspace": "~/Code/myproject"
}

For split panes, add right and/or bottom child objects to the pane_tree node using the same structure. The easiest way to understand the full schema is to enable session persistence, let it save a workspace you have set up, then inspect the resulting JSON file.

Each JSON file corresponds to a single workspace. There is currently no way to define a template layout that applies to multiple workspaces. See Roadmap.

How It Works

  • Save on switch: before switching away from a workspace, its full layout is captured (all windows, tabs, pane splits, working directories, and scrollback text) and written to disk
  • Lazy restore: after a restart, saved workspace names are read from the state directory and shown in the switcher alongside live workspaces. The full layout is only restored when you actually select a workspace (fast startup)
  • Startup restore: with session_restore_on_startup = true, the most recently used workspace (by access time) is automatically restored when WezTerm starts. If no saved state exists, WezTerm opens normally
  • In-memory workspaces: switching between workspaces that are already running is instant, no restoration needed, they're already in memory
  • Default workspace excluded: the "default" workspace is never saved or restored by default
  • Window position not restored: WezTerm does not expose a Lua API for getting or setting window screen position, so the plugin cannot save or restore where a window was placed on screen. Windows are spawned at the OS default position and resized to their saved dimensions. See Roadmap

Events Reference

The plugin emits events you can hook into for custom behavior:

Switcher lifecycle (pass GuiWindow):

EventWhenParameters
workspace_manager.switcher.openedSwitcher overlay appearswindow, pane
workspace_manager.switcher.canceledDismissed without action (Escape / click-outside)window, pane

Workspace transitions (pass MuxWindow):

EventWhenParameters
workspace_manager.workspace_switcher.switchingBefore any workspace switchmux_window, pane, old_workspace, new_workspace
workspace_manager.workspace_switcher.createdAfter creating a new workspacemux_window, pane, workspace_name[, path]
workspace_manager.workspace_switcher.selectedAfter switching to an existing workspacemux_window, pane, workspace_name

Workspace management (pass GuiWindow):

EventWhenParameters
workspace_manager.workspace_switcher.deletedAfter a workspace is deletedwindow, pane, workspace_name
workspace_manager.workspace_switcher.renamedAfter a workspace is renamed or mergedwindow, pane, old_name, new_name

Using resurrect.wezterm instead

If you prefer to use resurrect.wezterm directly for encryption, window/tab-level saves, its fuzzy loader, or remote domain support, see Using resurrect.wezterm instead for setup instructions.

Troubleshooting

Wezterm CLI not found

The plugin auto-detects the wezterm executable path using wezterm.executable_dir. If auto-detection fails (rare), you can manually set the path:

workspace_manager.wezterm_path = "/Applications/WezTerm.app/Contents/MacOS/wezterm"

Common paths:

  • macOS (App): /Applications/WezTerm.app/Contents/MacOS/wezterm
  • macOS (Homebrew): /opt/homebrew/bin/wezterm
  • Linux: /usr/bin/wezterm or /usr/local/bin/wezterm

Zoxide not found

If you get errors about zoxide not being found, set the full path:

workspace_manager.zoxide_path = "/usr/local/bin/zoxide"
-- or for Nix users:
workspace_manager.zoxide_path = "/etc/profiles/per-user/yourusername/bin/zoxide"

No workspaces shown

Make sure you have zoxide installed and have some directory history. You can also create workspaces manually using Ctrl+N from within the switcher, or provide your own entry list via get_choices (see Custom Choices).

Icons not displaying

The plugin uses Nerd Font icons. Make sure your terminal font includes Nerd Font symbols.

Notifications not working

Toast notifications are disabled by default. To enable them:

workspace_manager.notifications_enabled = true

Note: On macOS, toast notifications require a code-signed application. If you're running WezTerm built from source, installed via Nix, or using a non-signed build, notifications will not work. Use the official signed release from WezTerm releases if you want notifications.

Custom Choices

By default, the switcher shows directories from zoxide history below the workspace list. Set get_choices to replace zoxide with your own provider, or set it to false to disable suggestions entirely.

Field reference

Each entry returned by get_choices can be a path string or a table:

FieldRequiredDescription
nameYes (table only)The WezTerm workspace name, used when creating/switching to the workspace, and shown in the switcher unless label is set
pathNoWorking directory for the new workspace pane. Defaults to ~ when omitted. Supports ~ prefix.
labelNoDisplay name shown in the switcher instead of name. Useful for shorter or friendlier names.

A plain path string (e.g. "~/Code/my-project") is treated as both the path and the source for the workspace name (derived from the path).

How it works

  • Deduplication: entries whose name matches an already-active or saved workspace are automatically excluded, so the same workspace doesn't appear twice.
  • Normalization: absolute home paths are shown with a ~ prefix (e.g. /Users/ryan/Code/foo displays as ~/Code/foo). The ~ prefix is also accepted in path values and expanded automatically.
  • Workspace creation: selecting an entry creates a new WezTerm workspace. If path is set, the initial pane opens in that directory; otherwise it opens in ~.
  • Label persistence: if an entry has a label, it is shown in the switcher both before and after the workspace becomes live. The label is preserved as a display override for the workspace name.

Examples

-- Simplest: a list of paths. Workspace name derived from each path.
workspace_manager.get_choices = function()
  return {
    "~/Code/project-alpha",
    "~/Code/project-beta",
    "/opt/work/service-one",
  }
end

-- Named workspaces without a specific directory (cwd defaults to ~)
workspace_manager.get_choices = function()
  return {
    { name = "api" },
    { name = "frontend" },
  }
end

-- Named workspaces with a specific directory
workspace_manager.get_choices = function()
  return {
    { name = "api",      path = "~/Code/api-server" },
    { name = "frontend", path = "~/Code/web-app" },
  }
end

-- Custom display labels (name is the workspace name, label is what's shown in the switcher)
workspace_manager.get_choices = function()
  return {
    { name = "my-corp-monorepo", label = "Monorepo" },
    { name = "api-server",       label = "API", path = "~/Code/api-server" },
  }
end

To disable extra entries entirely (show only live and saved workspaces):

workspace_manager.get_choices = false

For more composable recipes (directory scanning, capped zoxide, static + dynamic lists), see examples.md.

Filtering Choices

Use filter_choices to control which suggested entries appear in the switcher. It accepts a table (path allowlist) or a function (predicate).

Note: filter_choices only affects custom/zoxide suggestions. Workspaces you've created, whether currently running or saved from a previous session, always appear in the switcher regardless of the filter.

Table allowlist

The simplest form: a list of exact path strings. Only custom/zoxide entries whose normalized path exactly matches one of the listed paths are kept.

workspace_manager.filter_choices = {
  "~/Code/project-alpha",
  "~/Code/project-beta",
}

Predicate function

For more control, provide a function that receives a choice object and returns true to keep it. Use this for subdirectory matching, metadata-based filtering, or anything beyond exact paths.

Available fields:

FieldWorkspace entriesCustom entriesZoxide entriesDescription
idyesyesyesWorkspace name or path identifier
labelyesyesyesDisplay name (before switcher formatting)
normalizedyesyesyesPath-normalized name (~-prefixed)
is_workspacetruefalsefalseWhether this is a live or saved workspace
is_savedyesn/an/aDisk-only (not a running workspace)
access_timeyesn/an/aLast access timestamp (0 if never accessed)
namen/ayesn/aExplicit workspace name from the provider
pathn/ayesn/aWorking directory path
has_pathn/ayesn/aWhether path was explicitly set
-- Only show zoxide suggestions under ~/Code (including subdirectories)
workspace_manager.filter_choices = function(choice)
  if choice.is_workspace then return true end  -- workspaces always shown; filter targets suggestions only
  return choice.normalized:find("^~/Code/") ~= nil
end

-- Auto-clean: hide saved workspaces not accessed in the last 30 days
workspace_manager.filter_choices = function(choice)
  if choice.is_saved and choice.access_time < os.time() - 30 * 86400 then
    return false
  end
  return true
end

-- Exclude entries matching a name pattern (e.g. scratch or temp workspaces)
workspace_manager.filter_choices = function(choice)
  return not choice.normalized:find("scratch") and not choice.normalized:find("tmp")
end

-- Combine: always show live workspaces, prune saved ones older than 14 days, limit zoxide to ~/Code
workspace_manager.filter_choices = function(choice)
  if choice.is_workspace and not choice.is_saved then return true end
  if choice.is_saved then return choice.access_time > os.time() - 14 * 86400 end
  return choice.normalized:find("^~/Code/") ~= nil
end

Comparisons

For a detailed look at how workspace-manager differs from smart_workspace_switcher.wezterm and resurrect.wezterm, see comparisons.md.

Roadmap

  • Template layouts: define a layout once and apply it to any workspace created under a given path

Acknowledgments

License

MIT