Navigate projects effortlessly with smart workspace switching and keyboard-driven navigation
demo2.mp4
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
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.
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 = trueso you can pick up where you left off. Selecting one spawns a new workspace and restores its full layout. Deleting a workspace viaCtrl+Dremoves both the live workspace and its state file, so it won't reappear. - Suggestions: directories from zoxide history by default, or a custom
get_choicesprovider. Not workspaces yet. Selecting one creates a new workspace at that path. Setget_choices = falseto disable suggestions entirely.
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.
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.
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- 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
LEADERkey configured in your wezterm config
When using apply_to_config(), the following default keybindings are added:
| Key | Action | Description |
|---|---|---|
LEADER + s |
Workspace switcher | Open the unified switcher |
LEADER + Shift-S |
Previous workspace | Switch to the previously active workspace (Alt-Tab toggle) |
CTRL + ] |
Next workspace | Cycle to the next workspace in alphabetical order |
CTRL + [ |
Previous workspace | Cycle to the previous workspace in alphabetical order |
While the switcher is open, the following keys are available:
| Key | Action |
|---|---|
Enter |
Switch to the selected workspace |
Ctrl + D |
Delete the selected workspace (re-opens switcher) |
Ctrl + N |
Create a new workspace by name |
Ctrl + P |
Create a new workspace at a path |
Ctrl + R |
Rename the selected workspace |
Escape |
Cancel |
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.
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.
| Option | Type | Default | Description |
|---|---|---|---|
wezterm_path |
string | Auto-detected | Path to wezterm executable (only needed if auto-detection fails) |
zoxide_path |
string | "zoxide" |
Path to zoxide binary (unused when get_choices is set) |
get_choices |
function/false | nil |
Custom entry provider function (see Custom Choices). Defaults to zoxide when nil. Set to false to disable suggestions entirely. |
filter_choices |
table/function | nil |
Filter switcher entries (see Filtering Choices). Table: exact path allowlist. Function: predicate receiving a choice object, return true to keep. |
show_current_workspace_in_switcher |
boolean | false |
Show current workspace in the switcher list |
show_current_workspace_hint |
boolean | true |
Show current workspace name in the switcher description bar |
start_in_fuzzy_mode |
boolean | true |
Start switcher in fuzzy search mode (false for positional shortcuts) |
notifications_enabled |
boolean | false |
Enable toast notifications (requires code-signed wezterm on macOS) |
workspace_count_format |
string | "compact" |
Display workspace counts: nil (disabled), "compact" (2w 3t 5p), or "full" (2 wins, 3 tabs, 5 panes) |
use_basename_for_workspace_names |
boolean | false |
Use directory basename instead of full path (falls back for duplicates) |
workspace_switcher_sort |
string | "recency" |
Sort order: "recency" (most recent first) or "alphabetical" |
switcher_keys |
table | nil |
Override in-switcher action key bindings (see Switcher Keys) |
show_switcher_hints |
boolean | true |
Show action key hints in the switcher description bar (both modes). Set to false to hide (use get_switcher_legend() instead) |
workspace_icon |
string | " " |
Icon glyph for workspace entries in the switcher |
workspace_icon_current |
string | nil |
Icon glyph for the active workspace (falls back to workspace_icon) |
entry_icon |
string | " " |
Icon glyph for custom/zoxide entries in the switcher |
colors |
table | nil |
Override theme colors (see Styling) |
Session persistence options (requires session_enabled = true):
| Option | Type | Default | Description |
|---|---|---|---|
session_enabled |
boolean | false |
Enable automatic workspace state save/restore |
session_periodic_save_interval |
number | 600 |
Seconds between periodic saves (nil to disable) |
session_periodic_save_all |
boolean | false |
Periodic save: true = all workspaces, false = active workspace only |
session_max_scrollback_lines |
number | 3500 |
Max scrollback lines to capture per pane |
session_exclude_workspaces |
table | {"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_dir |
string | nil |
Override state directory (default: ~/.local/share/wezterm/workspace_state/) |
session_on_pane_restore |
function | nil |
Custom per-pane restore callback (default: replays processes / injects scrollback) |
session_restore_on_startup |
boolean | false |
Automatically restore the most recently used workspace when WezTerm starts |
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 (requiressession_enabled = true)
workspace_manager.get_switcher_legend(): returns a formatted string of action key hints for use inset_right_status()(see Status Bar Legend)workspace_manager.get_zoxide_paths(limit?): returns up tolimitpaths from zoxide history as plain strings, for use inside aget_choicesfunction. Omitlimitto return all results. Respectszoxide_pathif set.
Registers event handlers, the workspace_switcher_actions key table, and default keybindings.
The unified switcher (LEADER + s) shows:
- Active workspaces (sorted by recency or alphabetically, marked with icon)
- Saved workspaces from previous sessions (also marked with icon, requires
session_enabled = true) - 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.
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 = falseThe 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)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:
| Key | Default | Used for |
|---|---|---|
prompt_accent |
"Lime" |
Workspace name/path text in prompt descriptions, e.g. the ~/ws in the switcher description and "Renaming: ~/ws" |
prompt_heading |
Bold | Label 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:
| Key | Default | Used for |
|---|---|---|
workspace_icon |
nil |
Icon glyph (terminal default) |
workspace_name |
nil |
Workspace name (terminal default) |
workspace_counts |
nil |
Count suffix, e.g. (2w 3t 5p) (terminal default) |
Active (current) workspace, each falls back to the matching workspace_* key:
| Key | Default | Used for |
|---|---|---|
workspace_icon_current |
nil |
Icon glyph |
workspace_name_current |
nil |
Workspace name |
workspace_counts_current |
nil |
Count suffix |
workspace_current_marker |
nil |
(current) text appended to the label, falls back to prompt_accent |
Custom/zoxide entries, each falls back to the matching workspace_* key:
| Key | Default | Used for |
|---|---|---|
entry_icon |
nil |
Icon glyph |
entry_name |
nil |
Entry 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
}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.
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.
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 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.
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.
- 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
The plugin emits events you can hook into for custom behavior:
Switcher lifecycle (pass GuiWindow):
| Event | When | Parameters |
|---|---|---|
workspace_manager.switcher.opened |
Switcher overlay appears | window, pane |
workspace_manager.switcher.canceled |
Dismissed without action (Escape / click-outside) | window, pane |
Workspace transitions (pass MuxWindow):
| Event | When | Parameters |
|---|---|---|
workspace_manager.workspace_switcher.switching |
Before any workspace switch | mux_window, pane, old_workspace, new_workspace |
workspace_manager.workspace_switcher.created |
After creating a new workspace | mux_window, pane, workspace_name[, path] |
workspace_manager.workspace_switcher.selected |
After switching to an existing workspace | mux_window, pane, workspace_name |
Workspace management (pass GuiWindow):
| Event | When | Parameters |
|---|---|---|
workspace_manager.workspace_switcher.deleted |
After a workspace is deleted | window, pane, workspace_name |
workspace_manager.workspace_switcher.renamed |
After a workspace is renamed or merged | window, pane, old_name, new_name |
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.
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/weztermor/usr/local/bin/wezterm
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"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).
The plugin uses Nerd Font icons. Make sure your terminal font includes Nerd Font symbols.
Toast notifications are disabled by default. To enable them:
workspace_manager.notifications_enabled = trueNote: 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.
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.
Each entry returned by get_choices can be a path string or a table:
| Field | Required | Description |
|---|---|---|
name |
Yes (table only) | The WezTerm workspace name, used when creating/switching to the workspace, and shown in the switcher unless label is set |
path |
No | Working directory for the new workspace pane. Defaults to ~ when omitted. Supports ~ prefix. |
label |
No | Display 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).
- 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/foodisplays as~/Code/foo). The~prefix is also accepted inpathvalues and expanded automatically. - Workspace creation: selecting an entry creates a new WezTerm workspace. If
pathis 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.
-- 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" },
}
endTo disable extra entries entirely (show only live and saved workspaces):
workspace_manager.get_choices = falseFor more composable recipes (directory scanning, capped zoxide, static + dynamic lists), see examples.md.
Use filter_choices to control which suggested entries appear in the switcher. It accepts a table (path allowlist) or a function (predicate).
Note:
filter_choicesonly 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.
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",
}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:
| Field | Workspace entries | Custom entries | Zoxide entries | Description |
|---|---|---|---|---|
id |
yes | yes | yes | Workspace name or path identifier |
label |
yes | yes | yes | Display name (before switcher formatting) |
normalized |
yes | yes | yes | Path-normalized name (~-prefixed) |
is_workspace |
true |
false |
false |
Whether this is a live or saved workspace |
is_saved |
yes | n/a | n/a | Disk-only (not a running workspace) |
access_time |
yes | n/a | n/a | Last access timestamp (0 if never accessed) |
name |
n/a | yes | n/a | Explicit workspace name from the provider |
path |
n/a | yes | n/a | Working directory path |
has_path |
n/a | yes | n/a | Whether 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
endFor a detailed look at how workspace-manager differs from smart_workspace_switcher.wezterm and resurrect.wezterm, see comparisons.md.
- Template layouts: define a layout once and apply it to any workspace created under a given path
- Zoxide integration inspired by MLFlexer/smart_workspace_switcher.wezterm
- Session persistence powered by code from MLFlexer/resurrect.wezterm (MIT licensed)
- In-overlay keyboard shortcuts technique (synthetic Enter via key table) demonstrated by sudo-tee
MIT