Skip to content

Instantly share code, notes, and snippets.

@johnlindquist
Last active December 5, 2025 16:29
Show Gist options
  • Select an option

  • Save johnlindquist/53b5638e82e1932cfc762ad23ad99d87 to your computer and use it in GitHub Desktop.

Select an option

Save johnlindquist/53b5638e82e1932cfc762ad23ad99d87 to your computer and use it in GitHub Desktop.
Why WezTerm: The AI-Native Terminal for the Agentic Era

Why WezTerm: The AI-Native Terminal for the Agentic Era

The Thesis

We are entering an era where AI agents can read, write, and modify configuration files on your behalf. In this new paradigm, the best tool is no longer the one with the prettiest GUI or the most menu optionsβ€”it's the one with the most programmable surface area.

WezTerm is that tool for terminals.

The Terminal Landscape in 2025

Terminal Configuration Scriptable Actions AI-Friendly
iTerm2 Plist/GUI AppleScript (limited) ❌ Binary prefs
Alacritty YAML None ⚠️ Config only
Kitty Custom format Limited scripting ⚠️ Partial
Hyper JavaScript Plugin system ⚠️ Electron bloat
Terminal.app Plist/GUI AppleScript ❌ Binary prefs
WezTerm Lua Full Lua runtime βœ… Complete

The difference isn't incremental. It's categorical.

What Makes WezTerm Different

1. Configuration IS Code

Most terminals separate "configuration" from "behavior." You set some options in a config file, and that's it. WezTerm's config file is a full Lua program that executes when the terminal starts.

-- This isn't configuration. This is code.
local function smart_split(window, pane)
  local dims = pane:get_dimensions()
  if dims.cols > dims.viewport_rows * 2.2 then
    pane:split({ direction = "Right", size = 0.5 })
  else
    pane:split({ direction = "Bottom", size = 0.5 })
  end
end

An AI can write this. An AI can modify this. An AI can extend this. Try doing that with iTerm2's binary preference files.

2. Runtime Access to Everything

WezTerm exposes its entire internal state to Lua:

  • Panes: Query dimensions, running processes, working directories
  • Tabs: List panes, get/set titles, manage layouts
  • Windows: Control appearance, override settings dynamically
  • Events: React to focus changes, key presses, window resizes
  • External tools: Shell out to any CLI tool (zoxide, git, fzf)
-- Query the current process to change behavior
local function is_vim(pane)
  local process = pane:get_foreground_process_info()
  return process and process.executable:find("vim") ~= nil
end

-- Now keybindings can be context-aware
if is_vim(pane) then
  -- Pass through to Vim
else
  -- Handle in WezTerm
end

3. Action Callbacks: Where AI Shines

The killer feature is wezterm.action_callback(). Any keybinding can execute arbitrary Lua code:

{
  key = "d",
  mods = "CMD",
  action = wezterm.action_callback(function(window, pane)
    -- Literally any logic you want
    -- An AI can write this for you based on your description
  end),
}

This is the unlock. When you say to an AI:

"Make Cmd+D create smart splits that respect a layout mode like Zellij"

The AI can actually implement that. Not "here's how you'd do it if WezTerm supported it" but actual working code that does exactly what you asked.

4. Event-Driven Architecture

WezTerm fires events that you can hook into:

wezterm.on('format-tab-title', function(tab, tabs, panes, config, hover, max_width)
  -- Complete control over tab rendering
end)

wezterm.on('update-status', function(window, pane)
  -- Build any status bar you want
end)

wezterm.on('window-resized', function(window, pane)
  -- React to window changes
end)

Every event is a hook where AI-generated code can live.

The AI Advantage

Before: The GUI Trap

Traditional workflow for customizing iTerm2:

  1. Open Preferences
  2. Click through tabs
  3. Find the setting (if it exists)
  4. Toggle it
  5. Realize you need something more complex
  6. Search for "iTerm2 smart splits"
  7. Find out it's not possible
  8. Give up or switch tools

After: The Conversational Workflow

WezTerm workflow with AI:

  1. Tell AI what you want
  2. AI writes the Lua code
  3. Paste into config
  4. Reload
  5. It works (or AI debugs it)

The terminal becomes as customizable as your imagination.

Real Examples from This Config

Every one of these features was built by describing the desired behavior:

Request Result
"Zellij-style layout modes" 200 lines of layout management code
"Fuzzy tab picker showing directory and process" Custom InputSelector with dynamic choices
"Status bar showing layout mode, workspace, and time" Event handler with formatted segments
"Cmd+T should open zoxide picker in new tab" Chained actions: spawn tab β†’ query zoxide β†’ show picker
"Different color schemes per project directory" Dynamic config overrides based on cwd

None of these are "features" of WezTerm. They're programs that run inside WezTerm.

Why Not the Others?

iTerm2

The gold standard for macOS terminals, but:

  • Configuration stored in binary plists
  • AppleScript integration is clunky and limited
  • No way to script pane/tab behavior
  • AI would need to generate AppleScript + GUI instructions

Alacritty

Fast and minimal, but:

  • YAML config onlyβ€”no scripting
  • No event system
  • No action callbacks
  • AI can only change static settings

Kitty

Closer to WezTerm in capability, but:

  • Custom config format (not a real language)
  • Scripting requires external "kittens" (Python scripts)
  • Less cohesive API
  • Split between config file and external scripts

Hyper

JavaScript-based, which sounds good, but:

  • Electron-based (resource heavy)
  • Plugin system is more about theming
  • Less terminal-native functionality
  • Performance issues

The Compounding Effect

Here's what happens over time:

Month 1: You ask AI to set up basic splits and styling.

Month 3: You've accumulated custom layouts, smart navigation, project-specific configs.

Month 6: Your terminal has features that don't exist in any other terminalβ€”because you (with AI) invented them.

Month 12: Your WezTerm config is a personalized terminal multiplexer that exactly matches your workflow.

This is only possible when the tool is programmable enough to grow with you.

The Future is Scriptable

We're moving toward a world where:

  1. AI agents edit config files as part of their workflow
  2. Natural language becomes the interface for customization
  3. The best tools are the most AI-accessible ones

WezTerm is built for this future:

  • Plain text config: AI can read and write it
  • Real programming language: AI can reason about it
  • Full API access: AI can build anything
  • Event system: AI can react to anything
  • No GUI dependency: Everything is code

Conclusion

The question isn't "which terminal has the best features today?"

The question is "which terminal can have any feature I need tomorrow?"

With GUI-based terminals, you're limited to what the developers imagined. With WezTerm, you're limited only by what you can describeβ€”and AI can describe a lot.

WezTerm isn't just a terminal. It's a terminal construction kit. And in the age of AI, that's exactly what you want.


Getting Started

  1. Install WezTerm: brew install --cask wezterm
  2. Create ~/.config/wezterm/wezterm.lua
  3. Start with the config in this repo
  4. Ask an AI to customize it for you

The future of terminal customization is conversational. WezTerm is ready for it.

-- ============================================================================
-- WEZTERM CONFIGURATION
-- ============================================================================
-- WezTerm is a GPU-accelerated terminal emulator written in Rust.
-- This config transforms WezTerm into a powerful terminal multiplexer,
-- combining features from tmux, Zellij, and iTerm2 into one cohesive setup.
--
-- Key design goals:
-- 1. MULTIPLEXER BUILT-IN: No need for tmux/Zellij - use native panes/tabs
-- 2. SMART LAYOUTS: Automatic pane arrangement like Zellij's swap layouts
-- 3. WORKSPACE MANAGEMENT: Project-based workspaces with zoxide integration
-- 4. FUZZY FINDING: Quick navigation with fuzzy pickers for everything
-- 5. MINIMAL UI: Clean interface that stays out of the way
-- ============================================================================
--
-- ============================================================================
-- WEZTERM'S SUPERPOWER: LUA SCRIPTING
-- ============================================================================
-- Unlike most terminal emulators that only offer static configuration,
-- WezTerm embeds a full Lua interpreter. This means you can:
--
-- 1. DEFINE CUSTOM FUNCTIONS - Write reusable logic for complex behaviors
-- 2. CREATE EVENT HANDLERS - React to terminal events (focus, resize, etc.)
-- 3. USE wezterm.action_callback() - Build keybindings that execute ANY Lua code
-- 4. ACCESS RUNTIME STATE - Query panes, tabs, windows, processes dynamically
-- 5. INTEGRATE EXTERNAL TOOLS - Shell out to zoxide, git, or any CLI tool
--
-- This transforms configuration from "set some options" to "program your terminal."
--
-- ============================================================================
-- WHERE WE USE THESE SUPERPOWERS IN THIS FILE:
-- ============================================================================
--
-- CUSTOM FUNCTIONS (reusable logic):
-- β€’ is_vim() - Detect Neovim for smart keybindings
-- β€’ get_layout_mode() / set_layout_mode() - Track layout state per tab
-- β€’ cycle_layout_mode() - Cycle through layout options
-- β€’ get_smart_split_direction() - Calculate optimal split direction
-- β€’ smart_new_pane() - Zellij-style intelligent pane creation
-- β€’ layouts.dev(), layouts.quad(), etc. - Programmatic layout templates
-- β€’ apply_layout() - Apply layouts by name
-- β€’ short_cwd() - Format paths for display
-- β€’ scheme_for_cwd() - Dynamic theming based on directory
--
-- EVENT HANDLERS (react to terminal events):
-- β€’ wezterm.on('format-tab-title', ...) - Custom tab title formatting
-- β€’ wezterm.on('format-window-title', ...) - Custom window title
-- β€’ wezterm.on('update-status', ...) - Dynamic status bar with live data
--
-- ACTION CALLBACKS (keybindings that run Lua):
-- β€’ Cmd+T β†’ Spawns tab + shows zoxide picker (two actions chained)
-- β€’ Cmd+P β†’ Builds dynamic tab list with process info for fuzzy search
-- β€’ Cmd+K β†’ Vim-aware scrollback clear (passes through in Neovim)
-- β€’ Alt+N β†’ Calls smart_new_pane() for layout-aware splitting
-- β€’ Alt+]/[ β†’ Cycles layout modes with toast notifications
-- β€’ Alt+Space β†’ Builds layout mode picker dynamically
-- β€’ Leader+Z β†’ Zen mode toggle (hide UI for focus)
--
-- KEY TABLES (modal keybindings):
-- β€’ resize_pane β†’ Enter resize mode with Leader+R, use hjkl freely
--
-- PLUGINS:
-- β€’ workspace_switcher - zoxide-powered workspace switching
-- β€’ resurrect - Session persistence (save/restore workspaces)
--
-- This level of customization is simply impossible in terminals like iTerm2,
-- Alacritty, or Kitty. WezTerm lets you BUILD your ideal terminal workflow.
-- ============================================================================
-- Import WezTerm's Lua API modules
local wezterm = require 'wezterm' -- Core API for configuration and utilities
local config = wezterm.config_builder() -- Type-safe config builder (validates options)
local act = wezterm.action -- Shorthand for action definitions (keybindings)
local mux = wezterm.mux -- Multiplexer API for programmatic control
-- ============================================================================
-- HELPER FUNCTIONS (POWER USER UTILITIES)
-- ============================================================================
-- Detect if the current pane is running Neovim/Vim
-- WHY: Many keybindings should behave differently in Vim vs shell.
-- For example, Cmd+K should clear scrollback in shell but pass through to Vim.
-- Used by: Smart navigation, smart scrollback clear
local function is_vim(pane)
local process_info = pane:get_foreground_process_info()
local process_name = process_info and process_info.executable or ""
return process_name:find("n?vim") ~= nil
end
-- ============================================================================
-- ZELLIJ-STYLE AUTO-LAYOUT SYSTEM
-- ============================================================================
-- WHY: Zellij popularized "swap layouts" - the ability to cycle through
-- different pane arrangements on the fly. This replicates that in WezTerm.
--
-- HOW IT WORKS:
-- 1. Each tab has a "layout mode" stored in wezterm.GLOBAL (persists across reloads)
-- 2. When creating new panes with Alt+n, the split direction is chosen based on mode
-- 3. Alt+[ and Alt+] cycle through layout modes
-- 4. A status bar indicator shows the current mode
--
-- MODES EXPLAINED:
-- - tiled: Grid layout that alternates split direction based on pane aspect ratio
-- └── Like a tiling window manager - splits the larger dimension
-- - vertical: All panes stacked top-to-bottom (good for wide monitors)
-- - horizontal: All panes side-by-side (good for tall monitors or reading logs)
-- - main-vertical: Main editor pane on left (60%), terminal stack on right (40%)
-- └── Classic development layout: code left, terminals right
-- - main-horizontal: Main pane on top (60%), stack on bottom (40%)
-- └── Useful for video editing or preview workflows
local DEFAULT_LAYOUT_MODE = 'tiled' -- Start in tiled mode for general use
-- ============================================================================
-- LAYOUT MODE STATE MANAGEMENT
-- ============================================================================
-- WHY wezterm.GLOBAL: WezTerm provides a GLOBAL table that persists across
-- config reloads. This is perfect for storing state like the current layout
-- mode per tab. Without this, state would be lost every time the config reloads.
--
-- WHY tostring(tab_id): Tab IDs are numbers, but Lua table keys work best as
-- strings when we want predictable serialization behavior.
-- Get current layout mode for a tab (stored in global)
local function get_layout_mode(tab)
-- Ensure the layout_modes table exists (defensive initialization)
wezterm.GLOBAL.layout_modes = wezterm.GLOBAL.layout_modes or {}
-- Return stored mode or default if none set
return wezterm.GLOBAL.layout_modes[tostring(tab:tab_id())] or DEFAULT_LAYOUT_MODE
end
-- Set layout mode for a tab
local function set_layout_mode(tab, mode)
wezterm.GLOBAL.layout_modes = wezterm.GLOBAL.layout_modes or {}
wezterm.GLOBAL.layout_modes[tostring(tab:tab_id())] = mode
end
-- ============================================================================
-- LAYOUT MODE DEFINITIONS
-- ============================================================================
-- WHY this structure: Having layout modes as a list of tables allows us to:
-- 1. Display them in the UI with human-readable names and descriptions
-- 2. Cycle through them in order with Alt+[ and Alt+]
-- 3. Build fuzzy picker choices dynamically
-- The 'id' is used internally, 'name' for display, 'desc' for help text.
local LAYOUT_MODES = {
{ id = 'tiled', name = 'Tiled', desc = 'Grid layout (Zellij default)' },
{ id = 'vertical', name = 'Vertical', desc = 'All panes stacked vertically' },
{ id = 'horizontal', name = 'Horizontal', desc = 'All panes side by side' },
{ id = 'main-vertical', name = 'Main+Vertical', desc = 'Main left, stack right' },
{ id = 'main-horizontal', name = 'Main+Horizontal', desc = 'Main top, stack bottom' },
}
-- ============================================================================
-- LAYOUT MODE CYCLING
-- ============================================================================
-- WHY cycling: Rather than memorizing which key goes to which layout, users
-- can just press Alt+] repeatedly until they find the layout they want.
-- This mirrors how Zellij's Tab key cycles through layouts.
-- Get next/previous layout mode (wraps around at boundaries)
local function cycle_layout_mode(current, direction)
-- Find the index of the current mode in the list
local current_idx = 1
for i, mode in ipairs(LAYOUT_MODES) do
if mode.id == current then
current_idx = i
break
end
end
-- Calculate new index with wrapping
local new_idx = current_idx + direction
if new_idx < 1 then new_idx = #LAYOUT_MODES end -- Wrap to end
if new_idx > #LAYOUT_MODES then new_idx = 1 end -- Wrap to start
return LAYOUT_MODES[new_idx].id
end
-- ============================================================================
-- SMART SPLIT DIRECTION CALCULATION (FIXED)
-- ============================================================================
-- WHY: The "tiled" mode uses aspect ratio to decide split direction, which
-- creates more balanced layouts. Wide panes split horizontally, tall panes
-- split vertically. This prevents the "all vertical strips" problem you get
-- with naive splitting.
--
-- FIX: Terminal cells aren't square - they're taller than wide (typical aspect
-- ratio ~2.2:1). We multiply height by 2.2 to compare actual visual dimensions
-- rather than raw cell counts. Without this, "tiled" mode creates too many
-- vertical splits.
--
-- For main layouts, we track pane count to know when to switch from creating
-- the main split to adding to the stack.
-- Calculate optimal split direction based on pane dimensions and layout mode
local function get_smart_split_direction(pane, mode, pane_count)
-- Get pane dimensions in cells (cols/rows, not pixels)
local dims = pane:get_dimensions()
local width = dims.cols -- Width in character columns
-- FIX: Multiply height by 2.2 to adjust for cell aspect ratio
-- Fonts are typically ~2.2x taller than wide, so raw row count
-- understates visual height. This prevents excessive vertical splits.
local height = dims.viewport_rows * 2.2 -- Adjusted for cell aspect ratio
if mode == 'vertical' then
-- Vertical stack: always split below current pane
return 'Bottom'
elseif mode == 'horizontal' then
-- Horizontal row: always split to the right
return 'Right'
elseif mode == 'main-vertical' then
-- Main-vertical: First split creates the right panel, subsequent splits
-- add to the stack on the right side (splitting that panel further)
return pane_count == 1 and 'Right' or 'Bottom'
elseif mode == 'main-horizontal' then
-- Main-horizontal: First split creates bottom panel, subsequent splits
-- add to the stack on the bottom (splitting that panel further)
return pane_count == 1 and 'Bottom' or 'Right'
else
-- 'tiled' mode: Intelligent aspect-ratio-based splitting
-- Split the larger visual dimension (now correctly compared)
return (width > height) and 'Right' or 'Bottom'
end
end
-- ============================================================================
-- SMART NEW PANE FUNCTION (FIXED)
-- ============================================================================
-- WHY: This is the core of the Zellij-style layout system. Instead of users
-- manually choosing split direction each time, this function makes intelligent
-- choices based on the current layout mode.
--
-- BEHAVIOR: Pressing Alt+n creates a new pane using the current layout mode's
-- logic. For main layouts, it correctly targets the stack pane (not the main).
--
-- FIX: The original code always split the LAST pane in the stack, which caused
-- the bottom pane to shrink infinitely (50% β†’ 25% β†’ 12.5%...) while the top
-- remained huge. The fix finds the LARGEST pane in the stack and splits that,
-- keeping the layout balanced automatically.
-- Smart new pane: creates a pane using Zellij-style auto-layout
local function smart_new_pane(window, pane)
local log_file = io.open(os.getenv("HOME") .. "/.config/wezterm/debug.log", "a")
local function log(msg)
if log_file then
log_file:write(os.date("%H:%M:%S") .. " " .. msg .. "\n")
log_file:flush()
end
end
log("smart_new_pane: CALLED")
local tab = window:active_tab()
if not tab then
log("ERROR: tab is nil")
if log_file then log_file:close() end
return
end
local mode = get_layout_mode(tab)
log("mode=" .. tostring(mode))
local panes = tab:panes()
local pane_count = #panes
log("pane_count=" .. tostring(pane_count))
-- Preserve working directory: new pane starts in same directory as current
local cwd = pane:get_current_working_dir()
local cwd_path = cwd and cwd.file_path or nil
log("cwd_path=" .. tostring(cwd_path))
-- =========================================================================
-- FIX: Handle Main Layouts with balanced stack splitting
-- =========================================================================
-- In main layouts (main-vertical, main-horizontal), panes[1] is the "main"
-- pane (editor), and panes[2..N] are the "stack" (terminals).
--
-- OLD BUG: Always splitting the last pane caused infinite halving:
-- Stack: [100%] β†’ [50%, 50%] β†’ [50%, 25%, 25%] β†’ [50%, 25%, 12.5%, 12.5%]
--
-- FIX: Find the largest pane in the stack and split THAT one:
-- Stack: [100%] β†’ [50%, 50%] β†’ [33%, 33%, 33%] (approximately)
if (mode == 'main-vertical' or mode == 'main-horizontal') and pane_count > 1 then
-- Find the largest pane in the stack (skip main pane at index 1)
local target_pane = panes[2] -- Default to first stack pane
local max_area = 0
for i = 2, pane_count do
local p = panes[i]
local dim = p:get_dimensions()
local area = dim.cols * dim.viewport_rows
if area > max_area then
max_area = area
target_pane = p
end
end
-- Split the largest stack pane (Bottom for main-vertical, Right for main-horizontal)
local dir = (mode == 'main-vertical') and 'Bottom' or 'Right'
local split_args = { direction = dir, size = 0.5 }
if cwd_path then
split_args.cwd = cwd_path
end
log("splitting largest pane, dir=" .. dir)
target_pane:split(split_args)
log("split complete")
if log_file then log_file:close() end
return
end
-- =========================================================================
-- Handle Tiled/Standard Layouts
-- =========================================================================
local direction = get_smart_split_direction(pane, mode, pane_count)
-- Size as a float (0.5 = 50%) - pane:split() expects float, not {Percent=N}
local size = 0.5
-- Special sizing for the first split in Main layouts (60/40 split)
if (mode == 'main-vertical' or mode == 'main-horizontal') and pane_count == 1 then
size = 0.4 -- New stack pane gets 40%, main keeps 60%
end
-- Perform the split on the current pane
local split_args = { direction = direction, size = size }
if cwd_path then
split_args.cwd = cwd_path
end
log("splitting current pane, dir=" .. direction .. ", size=" .. tostring(size))
local success, err = pcall(function()
pane:split(split_args)
end)
if success then
log("split complete")
else
log("ERROR: split failed: " .. tostring(err))
end
if log_file then log_file:close() end
end
-- ============================================================================
-- STATIC LAYOUT TEMPLATES
-- ============================================================================
-- WHY: While the Zellij-style system builds layouts incrementally, sometimes
-- you want to jump directly to a specific layout. These templates create
-- complete layouts in one action, useful for:
-- - Starting a new project with a known setup
-- - Quickly recreating a layout after closing tabs
-- - Demonstrating different layout possibilities
--
-- HOW TO USE: Press Cmd+Shift+L to open the layout picker, or use the
-- Leader+Shift+<key> shortcuts for direct access.
--
-- ASCII DIAGRAMS: Each function includes an ASCII diagram showing the final
-- layout. This helps visualize what you'll get before applying it.
local layouts = {}
-- ============================================================================
-- DEV LAYOUT: The classic development setup
-- ============================================================================
-- WHY 60/40 split: Gives the editor enough width for ~120 columns of code
-- while leaving meaningful space for terminal output.
--
-- WHY stacked terminals: Having multiple terminals (one for commands, one for
-- logs/tests) is more efficient than constantly switching in a single terminal.
--
-- USE CASE: General software development with an editor and terminal workflow.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ β”‚ terminal β”‚ <- git, commands
-- β”‚ editor β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ β”‚ terminal β”‚ <- tests, logs
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.dev(window, cwd)
-- Create a new tab (we don't modify the existing tab to preserve user's work)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Create right side (40% of total width) for terminal stack
local right = main:split({ direction = 'Right', size = { Percent = 40 }, cwd = cwd })
-- Split the right pane in half vertically for two terminals
right:split({ direction = 'Bottom', size = { Percent = 50 }, cwd = cwd })
-- Set a descriptive title for the tab
tab:set_title('dev')
return tab
end
-- ============================================================================
-- EDITOR LAYOUT: Maximum editing space with quick terminal access
-- ============================================================================
-- WHY 75/25 split: The editor gets maximum vertical space while the terminal
-- remains accessible for quick commands without switching tabs.
--
-- WHY bottom terminal: Keeps the mental model of "code above, output below"
-- which matches how many IDEs arrange their layouts.
--
-- USE CASE: Writing, documentation, or code review where you need to see as
-- much content as possible but occasionally run commands.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ β”‚
-- β”‚ editor β”‚ <- main focus area
-- β”‚ β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ terminal β”‚ <- quick commands
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.editor(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Bottom terminal gets 25% of height - enough for a few lines of output
main:split({ direction = 'Bottom', size = { Percent = 25 }, cwd = cwd })
tab:set_title('editor')
return tab
end
-- ============================================================================
-- THREE COLUMN LAYOUT: Comparing or monitoring multiple sources
-- ============================================================================
-- WHY equal thirds: Gives each column meaningful width for content comparison.
--
-- MATH NOTE: We split 66% right first, leaving 33% left. Then we split that
-- 66% in half (50%), giving us 33% + 33% = 66% for the remaining two columns.
--
-- USE CASE: Comparing files, monitoring multiple logs, or working with
-- microservices that need simultaneous terminals.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ β”‚ β”‚ β”‚
-- β”‚ left β”‚ center β”‚ right β”‚
-- β”‚ β”‚ β”‚ β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.three_col(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- First split: Create right 2/3 of screen (66%)
main:split({ direction = 'Right', size = { Percent = 66 }, cwd = cwd })
-- Second split: Split the active (right) pane in half to create middle and right
tab:active_pane():split({ direction = 'Right', size = { Percent = 50 }, cwd = cwd })
tab:set_title('3col')
return tab
end
-- ============================================================================
-- MONITORING LAYOUT: System monitoring with htop and logs
-- ============================================================================
-- WHY htop on top: System metrics are best viewed in wide format; htop uses
-- horizontal space for CPU/memory graphs and process columns.
--
-- WHY auto-start htop: This is the only layout that auto-starts commands
-- because monitoring is its dedicated purpose. Other layouts stay empty for
-- user flexibility.
--
-- USE CASE: Server monitoring, debugging performance issues, watching builds.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ htop β”‚ <- system metrics
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ logs β”‚ <- application logs
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.monitor(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Start htop in the top pane immediately
main:send_text('htop\n')
-- Create bottom pane (40% of height) for logs
local logs = main:split({ direction = 'Bottom', size = { Percent = 40 }, cwd = cwd })
-- Provide a helpful hint for the logs pane
logs:send_text('# tail -f your logs here\n')
tab:set_title('monitor')
return tab
end
-- ============================================================================
-- QUAD LAYOUT: Four equal panes for parallel work
-- ============================================================================
-- WHY four equal panes: Each quadrant gets 25% of the screen, which on a
-- modern monitor is still enough for meaningful work.
--
-- USE CASE: Running multiple services simultaneously, comparing 4 files,
-- monitoring microservices, or pair programming scenarios.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ top-left β”‚ top-right β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ bottom-left β”‚ bottom-right β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.quad(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Create right column (50% of width)
local right = main:split({ direction = 'Right', size = { Percent = 50 }, cwd = cwd })
-- Split left column into top-left and bottom-left
main:split({ direction = 'Bottom', size = { Percent = 50 }, cwd = cwd })
-- Split right column into top-right and bottom-right
right:split({ direction = 'Bottom', size = { Percent = 50 }, cwd = cwd })
tab:set_title('quad')
return tab
end
-- ============================================================================
-- STACKED LAYOUT: Three horizontal rows
-- ============================================================================
-- WHY three rows: Useful when each task needs full width (like reading logs)
-- but you need to see multiple things at once.
--
-- MATH NOTE: Similar to three_col, we split 66% first, then 50% of that,
-- giving us three roughly equal rows.
--
-- USE CASE: Log monitoring, following multiple streams, or workflows where
-- width matters more than height (like git log output).
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ top β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ middle β”‚
-- β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
-- β”‚ bottom β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.stacked(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- First split: bottom 66% of screen
main:split({ direction = 'Bottom', size = { Percent = 66 }, cwd = cwd })
-- Second split: split the 66% in half to create middle and bottom rows
tab:active_pane():split({ direction = 'Bottom', size = { Percent = 50 }, cwd = cwd })
tab:set_title('stacked')
return tab
end
-- ============================================================================
-- SIDE-BY-SIDE LAYOUT: Simple two-column split
-- ============================================================================
-- WHY: The most common split - comparing two files, code and docs, etc.
--
-- USE CASE: File comparison, side-by-side editing, or any two-pane workflow.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
-- β”‚ β”‚ β”‚
-- β”‚ left β”‚ right β”‚
-- β”‚ β”‚ β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
function layouts.side_by_side(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Simple 50/50 horizontal split
main:split({ direction = 'Right', size = { Percent = 50 }, cwd = cwd })
tab:set_title('split')
return tab
end
-- ============================================================================
-- FOCUS LAYOUT: Maximum focus with minimal distraction
-- ============================================================================
-- WHY 75/25 split: The main pane dominates while keeping a small sidebar
-- for quick reference or monitoring.
--
-- USE CASE: Deep work sessions where you want to minimize distractions but
-- still have quick access to a terminal for occasional commands.
-- β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”
-- β”‚ β”‚ β”‚
-- β”‚ main β”‚ side β”‚ <- small sidebar
-- β”‚ β”‚ β”‚
-- β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”˜
function layouts.focus(window, cwd)
local tab = window:mux_window():spawn_tab({ cwd = cwd })
local main = tab:active_pane()
-- Small 25% sidebar on the right - enough for a narrow terminal
main:split({ direction = 'Right', size = { Percent = 25 }, cwd = cwd })
tab:set_title('focus')
return tab
end
-- ============================================================================
-- LAYOUT METADATA
-- ============================================================================
-- WHY separate metadata: Having layout metadata separate from the functions
-- allows us to build UI elements (fuzzy pickers, menus) without executing
-- the layout code. The 'id' maps to the function name in the layouts table.
local layout_list = {
{ id = 'dev', name = 'Dev', desc = 'Editor + terminal stack (60/40)' },
{ id = 'editor', name = 'Editor', desc = 'Full editor + bottom terminal' },
{ id = 'three_col', name = '3 Column', desc = 'Three equal columns' },
{ id = 'quad', name = 'Quad', desc = 'Four equal panes' },
{ id = 'stacked', name = 'Stacked', desc = 'Three horizontal rows' },
{ id = 'side_by_side', name = 'Side by Side', desc = 'Two vertical columns' },
{ id = 'focus', name = 'Focus', desc = 'Main pane + small sidebar' },
{ id = 'monitor', name = 'Monitor', desc = 'htop + logs' },
}
-- ============================================================================
-- LAYOUT APPLICATION HELPER
-- ============================================================================
-- WHY a helper function: Centralizes the logic for applying layouts, including
-- getting the current working directory and error handling.
-- Apply a layout by name
local function apply_layout(window, layout_name)
local pane = window:active_pane()
-- Get current working directory to pass to the new tab
local cwd = pane:get_current_working_dir()
local cwd_path = cwd and cwd.file_path or nil
-- Look up the layout function and execute it
if layouts[layout_name] then
layouts[layout_name](window, cwd_path)
wezterm.log_info('Applied layout: ' .. layout_name)
else
wezterm.log_error('Unknown layout: ' .. layout_name)
end
end
-- ============================================================================
-- LAYOUT LAUNCHER BUILDER
-- ============================================================================
-- WHY dynamic building: Generates the fuzzy picker choices from layout_list,
-- ensuring the UI stays in sync with available layouts.
-- Build launcher entries for layouts (used by Cmd+Shift+L picker)
local function build_layout_launcher()
local entries = {}
for _, layout in ipairs(layout_list) do
table.insert(entries, {
label = layout.name .. ' - ' .. layout.desc,
action = wezterm.action_callback(function(window, pane)
apply_layout(window, layout.id)
end),
})
end
return entries
end
-- ============================================================================
-- PROJECT LAYOUTS (auto-apply layouts for specific directories/workspaces)
-- ============================================================================
-- WHY: Different projects have different needs. A web project might want the
-- dev layout by default, while a documentation project might want the editor
-- layout. This system lets you define those defaults.
--
-- HOW TO USE: Uncomment and modify the examples below. When you switch to a
-- workspace matching the pattern, the corresponding layout auto-applies.
--
-- PATTERNS: Use Lua string patterns (not regex). Common patterns:
-- - 'kit' matches any path containing 'kit'
-- - '^kit' matches paths starting with 'kit'
-- - 'kit$' matches paths ending with 'kit'
-- Simple pattern-to-layout mappings (uncomment and customize)
local project_layouts = {
-- Pattern matching on workspace name or directory
-- { pattern = 'kit', layout = 'dev' }, -- Use dev layout for Kit project
-- { pattern = 'lootbox', layout = 'dev' }, -- Use dev layout for Lootbox
-- { pattern = 'dotfiles', layout = 'editor' }, -- Use editor layout for dotfiles
}
-- ============================================================================
-- CUSTOM PROJECT LAYOUT FUNCTIONS
-- ============================================================================
-- WHY: Sometimes you need more control than a simple layout mapping provides.
-- These functions can start specific commands, set up watchers, etc.
--
-- These override the simple mappings above when the pattern matches.
local project_layout_functions = {
-- Example: Kit project with dev server and test watcher auto-started
-- ['kit'] = function(window, cwd)
-- local tab = window:mux_window():spawn_tab({ cwd = cwd })
-- local main = tab:active_pane()
-- -- Editor on left (60%)
-- local right = main:split({ direction = 'Right', size = { Percent = 40 }, cwd = cwd })
-- -- Dev server on top right - starts automatically
-- right:send_text('pnpm dev\n')
-- -- Tests on bottom right - starts automatically
-- local tests = right:split({ direction = 'Bottom', size = { Percent = 50 }, cwd = cwd })
-- tests:send_text('pnpm test --watch\n')
-- tab:set_title('kit')
-- return tab
-- end,
}
-- ============================================================================
-- PROJECT LAYOUT LOOKUP
-- ============================================================================
-- WHY two types: The 'function' type is checked first (for complex setups),
-- then 'layout' type (for simple mappings). This allows overriding simple
-- mappings with custom functions.
-- Get layout for a workspace/directory
local function get_project_layout(workspace_name)
-- Check custom functions first (higher priority)
for pattern, _ in pairs(project_layout_functions) do
if workspace_name:find(pattern) then
return { type = 'function', pattern = pattern }
end
end
-- Check simple mappings (lower priority)
for _, mapping in ipairs(project_layouts) do
if workspace_name:find(mapping.pattern) then
return { type = 'layout', layout = mapping.layout }
end
end
return nil -- No project-specific layout found
end
-- Apply project layout based on workspace name
local function apply_project_layout(window, workspace_name, cwd)
local project = get_project_layout(workspace_name)
if not project then return false end -- No matching project layout
if project.type == 'function' then
-- Execute the custom function
project_layout_functions[project.pattern](window, cwd)
return true
elseif project.type == 'layout' then
-- Apply the named layout
layouts[project.layout](window, cwd)
return true
end
return false
end
-- ============================================================================
-- WORKSPACE CHANGE EVENT (OPTIONAL)
-- ============================================================================
-- WHY commented out: Auto-applying layouts on workspace switch can be
-- surprising. Uncomment if you want this behavior.
--
-- HOW IT WORKS: When you switch workspaces (e.g., via smart_workspace_switcher),
-- this event fires and automatically applies the project layout if defined.
-- wezterm.on('workspace-switched', function(window, name, prev_name)
-- local pane = window:active_pane()
-- local cwd = pane:get_current_working_dir()
-- local cwd_path = cwd and cwd.file_path or nil
-- apply_project_layout(window, name, cwd_path)
-- end)
-- ============================================================================
-- UTILITY FUNCTIONS
-- ============================================================================
-- Shorten cwd for display (shows last 2 path components)
-- WHY: Full paths are too long for titles. "~/dev/my-project" becomes "dev/my-project"
-- This gives enough context to distinguish directories without wasting space.
local function short_cwd(pane)
local cwd = pane:get_current_working_dir()
if not cwd then return "~" end -- Fallback if cwd unavailable
local home = os.getenv("HOME") or ""
-- Replace home directory with ~ for readability
local path = cwd.file_path:gsub(home, "~")
-- Extract last two path components (e.g., "dev/my-project" from "~/dev/my-project")
local last_two = path:match("([^/]+/[^/]+)$")
return last_two or path:match("([^/]+)$") or path
end
-- ============================================================================
-- DYNAMIC COLOR SCHEME MAPPING
-- ============================================================================
-- WHY: Visual context helps identify which project you're in. Different colors
-- for different projects means you can instantly recognize which terminal
-- belongs to which workspace.
--
-- HOW TO USE: Add patterns and schemes below. The first matching pattern wins.
-- Patterns use Lua's string.find with plain=true (literal match, not regex).
-- Map cwd patterns to color schemes
local function scheme_for_cwd(pane)
local cwd = pane:get_current_working_dir()
if not cwd or not cwd.file_path then return nil end
local home = os.getenv("HOME") or ""
-- Clean up the path (remove file:// prefix, normalize home dir)
local path = cwd.file_path:gsub("^file://", ""):gsub(home, "~")
-- Define your project-to-scheme mappings here
-- Order matters: first match wins, so put specific paths before general ones
local mappings = {
{ pattern = "~/dev/zellij", scheme = "Gruvbox Dark" }, -- Zellij project
{ pattern = "~/dev/kit-container", scheme = "Gruvbox Dark" }, -- Kit project
{ pattern = "~", scheme = "Gruvbox Dark" }, -- Fallback for home
}
-- Find first matching pattern
for _, m in ipairs(mappings) do
if path:find(m.pattern, 1, true) then -- plain=true for literal matching
return m.scheme
end
end
return nil -- No match, use default scheme
end
-- ============================================================================
-- PLUGINS
-- ============================================================================
-- WHY smart_workspace_switcher: This plugin integrates with zoxide (a smarter
-- 'cd' command) to provide fuzzy workspace switching. Press Cmd+O to see your
-- frequently-used directories and switch to them as workspaces.
--
-- WHAT IS ZOXIDE: zoxide tracks your directory usage and ranks them by
-- "frecency" (frequency + recency). It's installed via: brew install zoxide
local workspace_switcher = wezterm.plugin.require("https://github.com/MLFlexer/smart_workspace_switcher.wezterm")
-- Tell the plugin where to find zoxide binary (Homebrew location on macOS)
workspace_switcher.zoxide_path = "/opt/homebrew/bin/zoxide"
-- ============================================================================
-- PLUGIN: RESURRECT (Session Persistence)
-- ============================================================================
-- WHY: Without this, closing WezTerm loses all your tabs, panes, and layout.
-- Resurrect automatically saves and restores your workspace state.
--
-- USAGE:
-- Leader+S = Save current workspace state
-- Leader+R = Restore saved workspace state
local resurrect = wezterm.plugin.require("https://github.com/MLFlexer/resurrect.wezterm")
-- ============================================================================
-- APPEARANCE CONFIGURATION
-- ============================================================================
-- These settings define the visual appearance of WezTerm.
-- COLOR SCHEME
-- WHY Gruvbox Dark: A warm, retro color scheme that's easy on the eyes for
-- long coding sessions. Popular in the terminal/vim community.
-- ALTERNATIVES: "Catppuccin Mocha", "Tokyo Night", "One Dark", "Dracula"
config.color_scheme = 'Gruvbox Dark'
-- FONT CONFIGURATION
-- WHY JetBrains Mono: Purpose-built for code with excellent ligatures,
-- clear distinctions between similar characters (0/O, 1/l/I), and
-- comfortable letter spacing.
-- ALTERNATIVES: "Fira Code", "Source Code Pro", "Hack", "Cascadia Code"
config.font = wezterm.font 'JetBrains Mono'
-- WHY 13pt: Large enough for comfortable reading without squinting,
-- small enough to fit meaningful content on screen
config.font_size = 13.0
-- ============================================================================
-- WINDOW CONFIGURATION
-- ============================================================================
-- WINDOW DECORATIONS
-- WHY "RESIZE" only: Removes the title bar for a cleaner look while keeping
-- resize handles. Title bar is redundant when using tabs.
-- OPTIONS: "FULL" (default macOS), "NONE" (no decorations), "RESIZE" (handles only)
config.window_decorations = "RESIZE"
-- WINDOW TRANSPARENCY (commented out by default)
-- WHY commented: Transparency can reduce readability and increase GPU usage.
-- Uncomment if you want to see your desktop through the terminal.
-- config.window_background_opacity = 0.50
-- config.macos_window_background_blur = 90 -- Blur effect for macOS
-- WINDOW PADDING
-- WHY padding: Small amount of space between content and window edges makes
-- text easier to read and looks more polished.
config.window_padding = {
left = 10, -- Space from left edge
right = 10, -- Space from right edge
top = 10, -- Space from top edge (below tab bar)
bottom = 10, -- Space from bottom edge
}
-- ============================================================================
-- TAB BAR CONFIGURATION
-- ============================================================================
-- ALWAYS SHOW TAB BAR
-- WHY: Even with one tab, showing the bar provides context (workspace name,
-- current directory) and visual consistency. You always know where to look.
config.hide_tab_bar_if_only_one_tab = false
-- USE SIMPLE TAB BAR
-- WHY: The "fancy" tab bar looks like native macOS tabs but has limited
-- customization. The simple bar allows full control via format-tab-title event.
config.use_fancy_tab_bar = false
-- TAB BAR POSITION
-- WHY bottom: Popular placement that feels natural - content above, controls below.
-- Many terminal users prefer this from tmux/vim statusline conventions.
config.tab_bar_at_bottom = true
-- TAB WIDTH LIMIT
-- WHY 32 chars: Prevents long paths from making tabs too wide while allowing
-- enough text for meaningful identification.
config.tab_max_width = 32
-- ============================================================================
-- PANE VISUAL DISTINCTION
-- ============================================================================
-- INACTIVE PANE DIMMING
-- WHY: When you have multiple panes, it's crucial to know which one has focus.
-- Dimming inactive panes provides an instant visual cue.
-- BRIGHTNESS 0.3: Quite dim - makes the active pane really stand out.
-- Increase to 0.5-0.7 if you need to see inactive pane content better.
config.inactive_pane_hsb = {
-- saturation = 0.9, -- Uncomment to desaturate colors too
brightness = 0.3, -- 30% brightness (quite dim)
}
-- PANE SPLIT LINE COLOR
-- WHY purple: Catppuccin Mauve provides good contrast against both light and
-- dark backgrounds, making split lines visible without being distracting.
config.colors = {
split = '#cba6f7', -- Catppuccin Mauve - visible purple split lines
}
-- ============================================================================
-- PERFORMANCE CONFIGURATION
-- ============================================================================
-- IMPORTANT: These settings impact battery life and system resources.
-- The defaults are conservative to prevent issues on various systems.
-- GPU BACKEND (commented out)
-- WHY commented: WebGpu can provide smoother rendering but may cause high GPU
-- usage on some systems. The default backend is usually fine.
-- UNCOMMENT if you want to experiment: config.front_end = "WebGpu"
-- config.front_end = "WebGpu"
-- FRAME RATE (commented out)
-- WHY commented: High FPS (120) causes constant GPU work even when idle,
-- which drains battery on laptops. Default (60) is plenty for terminals.
-- UNCOMMENT only if you notice tearing: config.max_fps = 120
-- config.max_fps = 120
-- ============================================================================
-- CURSOR CONFIGURATION
-- ============================================================================
-- CURSOR STYLE
-- WHY SteadyBar: A thin vertical bar is less distracting than a block cursor
-- and is familiar to users of modern text editors (VS Code, etc.).
-- WHY not blinking: Blinking cursors cause constant screen redraws, which
-- can impact battery life and is visually distracting.
-- ALTERNATIVE: Uncomment below for blinking bar:
-- config.default_cursor_style = 'BlinkingBar'
-- config.cursor_blink_rate = 500 -- milliseconds between blinks
config.default_cursor_style = 'SteadyBar'
-- ============================================================================
-- LEADER KEY CONFIGURATION
-- ============================================================================
-- WHY a leader key: Following tmux conventions, many commands require a
-- "prefix" key combination. This avoids conflicts with application shortcuts
-- and provides a consistent command entry point.
--
-- HOW IT WORKS: Press Ctrl+Q, then press the next key within 2 seconds.
-- Example: Ctrl+Q, then h to move focus left.
--
-- WHY Ctrl+Q: It's a common choice (tmux uses Ctrl+B by default), and Q is
-- easy to reach. Some people prefer Ctrl+A (like screen) or Ctrl+Space.
config.leader = { key = 'q', mods = 'CTRL', timeout_milliseconds = 2000 }
-- ============================================================================
-- KEYBINDINGS
-- ============================================================================
-- This config provides multiple "layers" of keybindings:
-- 1. CMD+key: macOS-native shortcuts (Cmd+W, Cmd+D, Cmd+T, etc.)
-- 2. LEADER+key: tmux-style commands (Ctrl+Q, then key)
-- 3. ALT+key: Zellij-style commands (Alt+n, Alt+h/j/k/l, etc.)
--
-- This layering lets you use whichever style you're most comfortable with.
config.keys = {
-- ============================================================================
-- MACOS-NATIVE SHORTCUTS (Cmd+key)
-- ============================================================================
-- These follow macOS conventions and work like other Mac apps.
-- CLOSE PANE: Cmd+W
-- WHY no confirm: Speed over safety. If you're used to Cmd+W closing things,
-- you expect immediate action. Use Cmd+Z in many apps to undo, though not here.
{
mods = "CMD",
key = "w",
action = act.CloseCurrentPane { confirm = false },
},
-- ============================================================================
-- NEW TAB WITH ZOXIDE PICKER: Cmd+T
-- ============================================================================
-- WHY: Standard Cmd+T creates a new tab, but we enhance it by immediately
-- showing a zoxide directory picker. This lets you jump to frequently-used
-- directories in one smooth motion instead of: new tab β†’ cd β†’ type path.
{
mods = "CMD",
key = "t",
action = wezterm.action_callback(function(window, pane)
-- Spawn a new tab first
local tab, new_pane, _ = window:mux_window():spawn_tab({})
-- Schedule the picker to appear after the tab is ready
-- WHY 0.001 delay: Gives WezTerm time to finish tab creation
wezterm.time.call_after(0.001, function()
-- Run zoxide query to get ranked directory list
local success, stdout = wezterm.run_child_process({ '/opt/homebrew/bin/zoxide', 'query', '-l' })
if not success then return end
-- Build the fuzzy picker choices
local choices = {}
for line in stdout:gmatch('[^\n]+') do
local home = os.getenv("HOME") or ""
-- Display paths with ~ for home directory (more readable)
local display = line:gsub("^" .. home, "~")
table.insert(choices, { id = line, label = display })
end
-- Show the fuzzy directory picker
window:perform_action(
act.InputSelector {
title = 'Select directory for new tab',
choices = choices,
fuzzy = true, -- Enable fuzzy matching (type to filter)
action = wezterm.action_callback(function(win, _, id, label)
if id then
-- cd to selected directory and clear the screen
new_pane:send_text('cd ' .. wezterm.shell_quote_arg(id) .. ' && clear\n')
end
end),
},
new_pane
)
end)
end),
},
-- ============================================================================
-- FUZZY TAB PICKER: Cmd+P
-- ============================================================================
-- WHY: VS Code-style Cmd+P for quick navigation. Shows all tabs with their
-- current directory and process, enabling fuzzy search to find what you want.
--
-- FORMAT:
-- - Single pane tabs: "~/path/to/dir Β· process_name"
-- - Multi-pane tabs: "dir1:zsh | dir2:nvim | dir3:node"
{
mods = "CMD",
key = "p",
action = wezterm.action_callback(function(window, pane)
local tabs = window:mux_window():tabs()
local home = os.getenv("HOME") or ""
local choices = {}
-- Build a choice entry for each tab
for _, t in ipairs(tabs) do
local panes = t:panes()
local pane_count = #panes
if pane_count == 1 then
-- SINGLE PANE: Show "~/path/to/dir Β· process_name"
local p = panes[1]
local cwd = p:get_current_working_dir()
local cwd_path = "~"
if cwd and cwd.file_path then
cwd_path = cwd.file_path:gsub("^" .. home, "~")
end
-- Get the running process (e.g., "zsh", "nvim", "node")
local process_info = p:get_foreground_process_info()
local process = "shell"
if process_info and process_info.executable then
process = process_info.executable:match("([^/]+)$") or "shell"
end
table.insert(choices, {
id = tostring(t:tab_id()),
label = string.format("%s Β· %s", cwd_path, process),
})
else
-- MULTIPLE PANES: Show "dir1:zsh | dir2:nvim | dir3:node"
local pane_list = {}
for i, p in ipairs(panes) do
local cwd = p:get_current_working_dir()
local cwd_path = "~"
if cwd and cwd.file_path then
-- Use short path (just the directory name) for compactness
local full_path = cwd.file_path:gsub("^" .. home, "~")
cwd_path = full_path:match("([^/]+)$") or full_path
end
local process_info = p:get_foreground_process_info()
local process = "shell"
if process_info and process_info.executable then
process = process_info.executable:match("([^/]+)$") or "shell"
end
table.insert(pane_list, cwd_path .. ":" .. process)
end
-- Join all pane descriptions with " | "
table.insert(choices, {
id = tostring(t:tab_id()),
label = table.concat(pane_list, " | "),
})
end
end
-- Show the fuzzy picker
window:perform_action(
act.InputSelector({
title = "Switch Tab",
choices = choices,
fuzzy = true,
action = wezterm.action_callback(function(inner_window, inner_pane, id, label)
if id then
-- Find and activate the selected tab
for _, t in ipairs(inner_window:mux_window():tabs()) do
if tostring(t:tab_id()) == id then
t:activate()
break
end
end
end
end),
}),
pane
)
end),
},
-- ============================================================================
-- WORKSPACE AND PANE NAVIGATION
-- ============================================================================
-- FUZZY WORKSPACE PICKER: Cmd+Shift+S
-- WHY: Quick access to all workspaces with fuzzy search
{ mods = "CMD|SHIFT", key = "s", action = act.ShowLauncherArgs { flags = "FUZZY|WORKSPACES" } },
-- SMART WORKSPACE SWITCHER: Cmd+O
-- WHY: Opens zoxide-powered workspace switcher from the plugin
-- This is the primary way to switch between project workspaces
{ mods = "CMD", key = "o", action = workspace_switcher.switch_workspace() },
-- PANE SELECTOR: Cmd+E
-- WHY: Shows number overlays on each pane for quick switching
-- Press Cmd+E, then type the number to focus that pane
{ mods = "CMD", key = "e", action = act.PaneSelect { alphabet = "1234567890", mode = "Activate" } },
-- SWAP PANES: Cmd+Shift+E
-- WHY: Rearrange pane positions by swapping with selected pane
{ mods = "CMD|SHIFT", key = "e", action = act.PaneSelect { alphabet = "1234567890", mode = "SwapWithActive" } },
-- ============================================================================
-- PANE SPLITTING (LEADER KEY)
-- ============================================================================
-- These follow tmux conventions for users familiar with that workflow.
-- SPLIT HORIZONTAL: Leader + - (dash)
-- WHY dash: Visual mnemonic - a dash is horizontal
{ mods = "LEADER", key = "-", action = act.SplitVertical { domain = "CurrentPaneDomain" } },
-- SPLIT VERTICAL: Leader + | (pipe)
-- WHY pipe: Visual mnemonic - a pipe is vertical
{ mods = "LEADER", key = "|", action = act.SplitHorizontal { domain = "CurrentPaneDomain" } },
-- SMART SPLIT: Cmd+D
-- WHY: Uses the smart layout system instead of always splitting right.
-- Respects the current layout mode (tiled, main-vertical, etc.)
{
mods = "CMD",
key = "d",
action = wezterm.action_callback(function(window, pane)
smart_new_pane(window, pane)
end),
},
-- SIMPLE SPLIT DOWN: Cmd+Shift+D
-- WHY: Sometimes you just want a vertical split regardless of layout mode.
-- This bypasses the smart layout system for manual control.
{
mods = "CMD|SHIFT",
key = "d",
action = act.SplitVertical { domain = "CurrentPaneDomain" },
},
-- ============================================================================
-- COMMAND PALETTE AND LAUNCHER
-- ============================================================================
-- COMMAND PALETTE: Cmd+K
-- WHY: VS Code-style access to all WezTerm commands
{ mods = "CMD", key = "k", action = act.ActivateCommandPalette },
-- LAUNCHER MENU: Cmd+L
-- WHY: Quick access to profiles, SSH hosts, etc.
{ mods = "CMD", key = "l", action = act.ShowLauncher },
-- ============================================================================
-- PANE NAVIGATION (LEADER KEY + VIM KEYS)
-- ============================================================================
-- tmux-style navigation using Leader + h/j/k/l
{ mods = "LEADER", key = "h", action = act.ActivatePaneDirection "Left" },
{ mods = "LEADER", key = "j", action = act.ActivatePaneDirection "Down" },
{ mods = "LEADER", key = "k", action = act.ActivatePaneDirection "Up" },
{ mods = "LEADER", key = "l", action = act.ActivatePaneDirection "Right" },
-- CLOSE PANE: Leader + X (with confirmation)
-- WHY confirm: Leader+x is less common than Cmd+W, so we add safety
{ mods = "LEADER", key = "x", action = act.CloseCurrentPane { confirm = true } },
-- ============================================================================
-- TAB MANAGEMENT (LEADER KEY)
-- ============================================================================
-- NEW TAB: Leader + C
-- WHY 'c': tmux convention (create)
{ mods = "LEADER", key = "c", action = act.SpawnTab "CurrentPaneDomain" },
-- NEXT TAB: Leader + N
{ mods = "LEADER", key = "n", action = act.ActivateTabRelative(1) },
-- PREVIOUS TAB: Leader + P
{ mods = "LEADER", key = "p", action = act.ActivateTabRelative(-1) },
-- QUICK TAB SWITCHING: Leader + Number
-- WHY: Direct access to tabs 1-5 (zero-indexed internally)
{ mods = "LEADER", key = "1", action = act.ActivateTab(0) },
{ mods = "LEADER", key = "2", action = act.ActivateTab(1) },
{ mods = "LEADER", key = "3", action = act.ActivateTab(2) },
{ mods = "LEADER", key = "4", action = act.ActivateTab(3) },
{ mods = "LEADER", key = "5", action = act.ActivateTab(4) },
-- ZOOM PANE: Leader + Z
-- WHY: Toggle fullscreen for current pane (tmux-style)
-- Useful for temporarily focusing on one pane
{ mods = "LEADER", key = "z", action = act.TogglePaneZoomState },
-- COPY MODE: Leader + [
-- WHY: tmux-style scrollback/copy mode entry
-- Navigate with vim keys, select with v, yank with y
{ mods = "LEADER", key = "[", action = act.ActivateCopyMode },
-- ============================================================================
-- ZELLIJ-STYLE AUTO-LAYOUT (ALT KEY)
-- ============================================================================
-- These keybindings replicate Zellij's workflow using Alt as the modifier.
-- SMART NEW PANE: Alt+N
-- WHY: Creates a new pane using the current layout mode's logic
-- This is the main way to add panes when using the auto-layout system
{
mods = "ALT",
key = "n",
action = wezterm.action_callback(function(window, pane)
smart_new_pane(window, pane)
end),
},
-- CYCLE LAYOUT MODE FORWARD: Alt+]
-- WHY: Quickly cycle through layout modes to find the one you want
-- Shows a toast notification with the new mode name
{
mods = "ALT",
key = "]",
action = wezterm.action_callback(function(window, pane)
local tab = window:active_tab()
local current = get_layout_mode(tab)
local new_mode = cycle_layout_mode(current, 1) -- +1 = forward
-- Store the new mode
wezterm.GLOBAL.layout_modes = wezterm.GLOBAL.layout_modes or {}
wezterm.GLOBAL.layout_modes[tostring(tab:tab_id())] = new_mode
-- Find human-readable name for the toast notification
local mode_name = new_mode
for _, m in ipairs(LAYOUT_MODES) do
if m.id == new_mode then mode_name = m.name .. ' - ' .. m.desc break end
end
-- Show feedback to user
window:toast_notification('WezTerm', 'Layout: ' .. mode_name, nil, 2000)
wezterm.log_info('Layout mode changed to: ' .. new_mode)
end),
},
-- CYCLE LAYOUT MODE BACKWARD: Alt+[
-- WHY: Go the other direction in case you overshoot
{
mods = "ALT",
key = "[",
action = wezterm.action_callback(function(window, pane)
local tab = window:active_tab()
local current = get_layout_mode(tab)
local new_mode = cycle_layout_mode(current, -1) -- -1 = backward
wezterm.GLOBAL.layout_modes = wezterm.GLOBAL.layout_modes or {}
wezterm.GLOBAL.layout_modes[tostring(tab:tab_id())] = new_mode
local mode_name = new_mode
for _, m in ipairs(LAYOUT_MODES) do
if m.id == new_mode then mode_name = m.name .. ' - ' .. m.desc break end
end
window:toast_notification('WezTerm', 'Layout: ' .. mode_name, nil, 2000)
wezterm.log_info('Layout mode changed to: ' .. new_mode)
end),
},
-- LAYOUT MODE PICKER: Alt+Space
-- WHY: Fuzzy picker for directly selecting a layout mode
-- Useful when you know which mode you want without cycling
{
mods = "ALT",
key = "Space",
action = wezterm.action.InputSelector({
title = "Select Layout Mode (Zellij-style)",
-- Build choices from LAYOUT_MODES at config load time
choices = (function()
local choices = {}
for _, mode in ipairs(LAYOUT_MODES) do
table.insert(choices, {
id = mode.id,
label = mode.name .. ' - ' .. mode.desc,
})
end
return choices
end)(),
action = wezterm.action_callback(function(window, pane, id, label)
if id then
local tab = window:active_tab()
wezterm.GLOBAL.layout_modes = wezterm.GLOBAL.layout_modes or {}
wezterm.GLOBAL.layout_modes[tostring(tab:tab_id())] = id
window:toast_notification('WezTerm', 'Layout: ' .. label, nil, 2000)
end
end),
}),
},
-- ROTATE PANES: Alt+R
-- WHY: Cycle pane positions clockwise - useful for rearranging after
-- panes are created in the wrong order
{
mods = "ALT",
key = "r",
action = wezterm.action_callback(function(window, pane)
window:perform_action(act.RotatePanes "Clockwise", pane)
end),
},
-- EQUALIZE PANES: Alt+=
-- WHY: Reset all panes to equal sizes after manual resizing
-- NOTE: WezTerm doesn't have native equalize, so we reset zoom state instead
{
mods = "ALT",
key = "=",
action = wezterm.action_callback(function(window, pane)
-- Reset zoom state (which at least ensures no pane is maximized)
window:perform_action(act.SetPaneZoomState(false), pane)
window:toast_notification('WezTerm', 'Panes equalized (zoom reset)', nil, 1500)
end),
},
-- ============================================================================
-- ZELLIJ-STYLE PANE NAVIGATION (ALT + VIM KEYS)
-- ============================================================================
-- WHY Alt+hjkl: Zellij uses Alt as its primary modifier, making pane
-- navigation instantly accessible without a leader key prefix.
{ mods = "ALT", key = "h", action = act.ActivatePaneDirection "Left" },
{ mods = "ALT", key = "j", action = act.ActivatePaneDirection "Down" },
{ mods = "ALT", key = "k", action = act.ActivatePaneDirection "Up" },
{ mods = "ALT", key = "l", action = act.ActivatePaneDirection "Right" },
-- ============================================================================
-- ZELLIJ-STYLE PANE RESIZING (ALT+SHIFT + VIM KEYS)
-- ============================================================================
-- WHY Alt+Shift: Shift is the "bigger/more" modifier, so Alt+Shift+hjkl
-- does the "bigger" version of navigation: resizing.
-- 5 is the number of cells to resize by each press.
{ mods = "ALT|SHIFT", key = "h", action = act.AdjustPaneSize { "Left", 5 } },
{ mods = "ALT|SHIFT", key = "j", action = act.AdjustPaneSize { "Down", 5 } },
{ mods = "ALT|SHIFT", key = "k", action = act.AdjustPaneSize { "Up", 5 } },
{ mods = "ALT|SHIFT", key = "l", action = act.AdjustPaneSize { "Right", 5 } },
-- TOGGLE FLOATING/ZOOM: Alt+F
-- WHY: Zellij has floating panes; WezTerm doesn't, so we use zoom as a
-- substitute. This toggles the current pane to/from fullscreen.
{
mods = "ALT",
key = "f",
action = wezterm.action_callback(function(window, pane)
window:perform_action(act.TogglePaneZoomState, pane)
end),
},
-- CLOSE PANE: Alt+X
-- WHY: Quick pane closing without confirmation (Zellij-style speed)
{
mods = "ALT",
key = "x",
action = wezterm.action_callback(function(window, pane)
window:perform_action(act.CloseCurrentPane { confirm = false }, pane)
end),
},
-- ============================================================================
-- DIRECT PANE SWITCHING (CMD + NUMBER)
-- ============================================================================
-- WHY: In addition to Cmd+E's overlay selector, direct number access is
-- faster when you know which pane you want (1-9).
{ mods = "CMD", key = "1", action = act.ActivatePaneByIndex(0) },
{ mods = "CMD", key = "2", action = act.ActivatePaneByIndex(1) },
{ mods = "CMD", key = "3", action = act.ActivatePaneByIndex(2) },
{ mods = "CMD", key = "4", action = act.ActivatePaneByIndex(3) },
{ mods = "CMD", key = "5", action = act.ActivatePaneByIndex(4) },
{ mods = "CMD", key = "6", action = act.ActivatePaneByIndex(5) },
{ mods = "CMD", key = "7", action = act.ActivatePaneByIndex(6) },
{ mods = "CMD", key = "8", action = act.ActivatePaneByIndex(7) },
{ mods = "CMD", key = "9", action = act.ActivatePaneByIndex(8) },
-- ============================================================================
-- STATIC LAYOUT TEMPLATES (CMD+SHIFT+L AND LEADER+SHIFT+KEY)
-- ============================================================================
-- LAYOUT PICKER: Cmd+Shift+L
-- WHY: Opens a fuzzy picker with all available static layouts
-- Select one to create a new tab with that layout applied
{
mods = "CMD|SHIFT",
key = "l",
action = wezterm.action.InputSelector({
title = "Select Layout",
choices = (function()
local choices = {}
for _, layout in ipairs(layout_list) do
table.insert(choices, {
id = layout.id,
label = layout.name .. ' - ' .. layout.desc,
})
end
return choices
end)(),
action = wezterm.action_callback(function(window, pane, id, label)
if id then
apply_layout(window, id)
end
end),
}),
},
-- QUICK LAYOUT HOTKEYS: Leader+Shift+<key>
-- WHY: Direct access to specific layouts without opening the picker
-- Useful when you know exactly which layout you want
{ mods = "LEADER|SHIFT", key = "d", action = wezterm.action_callback(function(w, p) apply_layout(w, 'dev') end) },
{ mods = "LEADER|SHIFT", key = "e", action = wezterm.action_callback(function(w, p) apply_layout(w, 'editor') end) },
{ mods = "LEADER|SHIFT", key = "3", action = wezterm.action_callback(function(w, p) apply_layout(w, 'three_col') end) },
{ mods = "LEADER|SHIFT", key = "4", action = wezterm.action_callback(function(w, p) apply_layout(w, 'quad') end) },
{ mods = "LEADER|SHIFT", key = "s", action = wezterm.action_callback(function(w, p) apply_layout(w, 'stacked') end) },
{ mods = "LEADER|SHIFT", key = "v", action = wezterm.action_callback(function(w, p) apply_layout(w, 'side_by_side') end) },
{ mods = "LEADER|SHIFT", key = "f", action = wezterm.action_callback(function(w, p) apply_layout(w, 'focus') end) },
{ mods = "LEADER|SHIFT", key = "m", action = wezterm.action_callback(function(w, p) apply_layout(w, 'monitor') end) },
-- ============================================================================
-- POWER USER FEATURES
-- ============================================================================
-- SESSION PERSISTENCE: Leader+S / Leader+R
-- WHY: Save your entire workspace layout before closing, restore it later
{
mods = "LEADER",
key = "s",
action = wezterm.action_callback(function(win, pane)
resurrect.save_state(resurrect.workspace_state.get_workspace_state())
win:toast_notification('WezTerm', 'Workspace saved', nil, 1000)
end),
},
-- QUICK SELECT: Leader+Space
-- WHY: Highlights all URLs, hashes, IPs on screen. Type the label to copy.
-- Game-changer for copying git hashes, URLs, etc. without using the mouse.
{ mods = "LEADER", key = "Space", action = act.QuickSelect },
-- PROMOTE PANE: Leader+Enter
-- WHY: In main layouts, quickly swap any pane with the main (largest) pane
-- Useful for "promoting" a terminal from the stack to the editor position
{ mods = "LEADER", key = "Enter", action = act.PaneSelect { mode = "SwapWithActive" } },
-- SCROLLBACK SEARCH: Cmd+F
-- WHY: Direct access to search without entering copy mode first
{ mods = "CMD", key = "f", action = act.Search 'CurrentSelectionOrEmptyString' },
-- ZEN MODE TOGGLE: Leader+Z (overrides pane zoom)
-- WHY: Sometimes you want zero distractions - hide tab bar, remove padding
-- NOTE: This replaces the default Leader+Z zoom. Use Alt+F for pane zoom instead.
{
mods = "LEADER",
key = "z",
action = wezterm.action_callback(function(window, pane)
local overrides = window:get_config_overrides() or {}
if overrides.enable_tab_bar == false then
-- Exit Zen Mode: restore tab bar and padding
overrides.enable_tab_bar = true
overrides.window_padding = { left = 10, right = 10, top = 10, bottom = 10 }
window:toast_notification('WezTerm', 'Zen Mode OFF', nil, 1000)
else
-- Enter Zen Mode: hide tab bar, remove padding
overrides.enable_tab_bar = false
overrides.window_padding = { left = 0, right = 0, top = 0, bottom = 0 }
window:toast_notification('WezTerm', 'Zen Mode ON', nil, 1000)
end
window:set_config_overrides(overrides)
end),
},
-- SMART SCROLLBACK CLEAR: Cmd+K (Vim-aware)
-- WHY: Cmd+K normally clears scrollback, but in Neovim it would break the UI.
-- This version passes through to Vim, only clears in regular shell.
{
mods = "CMD",
key = "k",
action = wezterm.action_callback(function(window, pane)
if is_vim(pane) then
-- In Vim: pass the key through
window:perform_action(act.SendKey({ key = 'k', mods = 'CMD' }), pane)
else
-- In shell: clear scrollback and redraw prompt
window:perform_action(act.ClearScrollback 'ScrollbackOnly', pane)
window:perform_action(act.SendKey({ key = 'L', mods = 'CTRL' }), pane)
end
end),
},
} -- End of config.keys
-- ============================================================================
-- KEY TABLES (MODAL KEYBINDINGS)
-- ============================================================================
-- WHY: Holding Alt+Shift+h repeatedly to resize is ergonomically painful.
-- Key Tables let you enter a "mode" where single keys perform actions.
--
-- RESIZE MODE: Press Leader+R to enter, then use h/j/k/l freely to resize.
-- Press Escape to exit. Much more comfortable for fine-grained adjustments.
config.key_tables = {
resize_pane = {
{ key = 'h', action = act.AdjustPaneSize { 'Left', 1 } },
{ key = 'j', action = act.AdjustPaneSize { 'Down', 1 } },
{ key = 'k', action = act.AdjustPaneSize { 'Up', 1 } },
{ key = 'l', action = act.AdjustPaneSize { 'Right', 1 } },
{ key = 'Escape', action = 'PopKeyTable' },
{ key = 'Enter', action = 'PopKeyTable' },
},
}
-- Add the resize mode activation key (must be done after config.keys is defined)
table.insert(config.keys, {
mods = "LEADER",
key = "r",
action = act.ActivateKeyTable { name = 'resize_pane', one_shot = false },
})
-- ============================================================================
-- HYPERLINK RULES
-- ============================================================================
-- WHY: Make URLs, emails, and custom patterns Cmd+clickable.
-- Much faster than selecting and copying manually.
config.hyperlink_rules = {
-- Standard URLs (http, https, ftp, file, mailto, ssh, git)
{ regex = '\\b\\w+://[\\w.-]+\\.[a-z]{2,15}\\S*\\b', format = '$0' },
-- Email addresses
{ regex = '\\b[\\w.+-]+@[\\w-]+(\\.[\\w-]+)+\\b', format = 'mailto:$0' },
-- File paths (starting with / or ~)
{ regex = '\\b(/[\\w.-]+)+/?\\b', format = 'file://$0' },
-- GitHub-style issue/PR references (uncomment and customize for your repos)
-- { regex = '#(\\d+)', format = 'https://github.com/YOUR-ORG/YOUR-REPO/issues/$1' },
-- { regex = 'gh-(\\d+)', format = 'https://github.com/YOUR-ORG/YOUR-REPO/issues/$1' },
}
-- ============================================================================
-- VISUAL BELL (No Audio)
-- ============================================================================
-- WHY: System beeps are annoying, especially in quiet environments or with
-- headphones. Visual bell provides feedback without sound.
config.audible_bell = "Disabled"
config.visual_bell = {
fade_in_duration_ms = 75,
fade_out_duration_ms = 75,
target = 'CursorColor', -- Flash the cursor instead of the whole screen
}
-- ============================================================================
-- DYNAMIC THEME (MACOS APPEARANCE)
-- ============================================================================
-- WHY: macOS can switch between light and dark mode automatically. This
-- function returns an appropriate color scheme based on the current setting.
--
-- COMMENTED OUT by default because a single scheme (Gruvbox Dark) is used.
local function scheme_for_appearance(appearance)
-- Check if the appearance contains "Dark" (e.g., "Dark" or "LightDark")
if appearance:find("Dark") then
return "Catppuccin Mocha" -- Dark theme
else
return "Catppuccin Latte" -- Light theme
end
end
-- ENABLE DYNAMIC THEME: Uncomment this line to follow macOS appearance
-- This will switch between Catppuccin Mocha (dark) and Latte (light)
-- config.color_scheme = scheme_for_appearance(wezterm.gui.get_appearance())
-- ============================================================================
-- PLUGIN INITIALIZATION
-- ============================================================================
-- Apply plugin configurations to the config object
workspace_switcher.apply_to_config(config)
-- ============================================================================
-- CUSTOM TAB TITLES
-- ============================================================================
-- WHY custom titles: Default titles are often unhelpful (just "zsh" or blank).
-- Custom titles show directory + process + activity indicators, making tabs
-- much more useful when you have many open.
--
-- FORMAT: [indicators] process Β· full_path
-- INDICATORS:
-- ● = Unseen output (activity in background tab)
-- πŸ” = Pane is zoomed (fullscreen)
--
-- Both process and path are searchable via Cmd+P fuzzy finder.
wezterm.on('format-tab-title', function(tab, tabs, panes, config, hover, max_width)
local pane = tab.active_pane
local cwd = pane.current_working_dir
local home = os.getenv("HOME") or ""
-- Get full path (replacing home dir with ~)
local full_path = "~"
if cwd and cwd.file_path then
full_path = cwd.file_path:gsub("^" .. home, "~")
end
-- Get the foreground process name (what's actually running in the pane)
local process = pane.foreground_process_name or ""
process = process:match("([^/]+)$") or process -- Extract basename
if process == "" then process = "shell" end -- Fallback for empty
-- UNSEEN OUTPUT INDICATOR
-- WHY: When a background tab has new output, you want to know without
-- switching to it. The ● dot is a visual "notification badge".
local indicator = ""
for _, p in ipairs(tab.panes) do
if p.has_unseen_output then
indicator = "● "
break
end
end
-- ZOOMED PANE INDICATOR
-- WHY: Remind yourself that a pane is zoomed (might explain why you
-- can't see other panes in this tab)
local zoom = ""
if tab.active_pane.is_zoomed then
zoom = "πŸ” "
end
-- Build the title: " ● πŸ” nvim Β· ~/dev/project "
local title = string.format(" %s%s%s Β· %s ", indicator, zoom, process, full_path)
-- CATPPUCCIN MOCHA COLORS
-- Active tab: Lighter background (Surface0), bright text
-- Inactive tab: Darker background (Base), dimmed text
if tab.is_active then
return {
{ Background = { Color = '#313244' } }, -- Surface0 (slightly lighter)
{ Foreground = { Color = '#cdd6f4' } }, -- Text (bright)
{ Text = title },
}
end
return {
{ Background = { Color = '#1e1e2e' } }, -- Base (dark)
{ Foreground = { Color = '#6c7086' } }, -- Overlay0 (dimmed)
{ Text = title },
}
end)
-- ============================================================================
-- WINDOW TITLE
-- ============================================================================
-- WHY: When you have multiple WezTerm windows, the title helps identify them
-- in the app switcher and dock. Shows the current directory (shortened).
wezterm.on('format-window-title', function(tab, pane, tabs, panes, cfg)
-- Use the short_cwd helper to get a compact path representation
return short_cwd(pane)
end)
-- ============================================================================
-- STATUS BAR (RIGHT SIDE)
-- ============================================================================
-- WHY: The status bar provides at-a-glance information without cluttering
-- the terminal content. Shows:
-- - Mode indicator (copy mode, search mode, etc.) - RED
-- - Leader key indicator (when Ctrl+Q is pressed) - PEACH/ORANGE
-- - Layout mode (tiled/vertical/etc. with icon) - PURPLE
-- - Workspace name - BLUE
-- - Current working directory - GRAY
-- - Time - DARK GRAY
--
-- LEFT STATUS is disabled (removed hostname display)
wezterm.on("update-status", function(window, pane)
-- DYNAMIC COLOR SCHEME BASED ON CWD
-- Check if the current directory has a custom color scheme mapping
local scheme = scheme_for_cwd(pane)
if scheme then
-- Override the color scheme for this window based on directory
window:set_config_overrides({ color_scheme = scheme })
else
-- Clear any overrides to use the default scheme
window:set_config_overrides({})
end
-- DISABLE LEFT STATUS BAR
-- WHY: The hostname (e.g., "johns") isn't useful in most local setups
-- and wastes space. Remove this line to re-enable hostname display.
window:set_left_status("")
-- BUILD RIGHT STATUS BAR
-- We build an array of formatting directives that WezTerm renders
local cells = {}
-- MODE INDICATOR (copy mode, search mode, etc.)
-- WHY: When you're in a special mode, you need to know to exit correctly
local mode = window:active_key_table()
if mode then
table.insert(cells, { Foreground = { Color = "#f38ba8" } }) -- Red/Pink
table.insert(cells, { Attribute = { Intensity = "Bold" } })
table.insert(cells, { Text = " " .. mode:upper() .. " β”‚" })
table.insert(cells, { Attribute = { Intensity = "Normal" } })
end
-- LEADER KEY INDICATOR
-- WHY: When you press Ctrl+Q (leader), you need visual confirmation
-- that WezTerm is waiting for the next key
if window:leader_is_active() then
table.insert(cells, { Foreground = { Color = "#fab387" } }) -- Peach/Orange
table.insert(cells, { Attribute = { Intensity = "Bold" } })
table.insert(cells, { Text = " LEADER β”‚" })
table.insert(cells, { Attribute = { Intensity = "Normal" } })
end
-- LAYOUT MODE INDICATOR (Zellij-style)
-- WHY: Reminds you which layout mode is active for this tab
-- Each mode has an icon for quick visual identification
local tab = window:active_tab()
local layout_mode = get_layout_mode(tab)
local layout_icon = ({
['tiled'] = '⊞', -- Grid icon
['vertical'] = '⬍', -- Vertical arrows
['horizontal'] = '⬌', -- Horizontal arrows
['main-vertical'] = 'β—§', -- Left-heavy box
['main-horizontal'] = 'β¬’', -- Top-heavy box
})[layout_mode] or '⊞'
table.insert(cells, { Foreground = { Color = "#cba6f7" } }) -- Mauve/Purple
table.insert(cells, { Text = " " .. layout_icon .. " " .. layout_mode })
-- WORKSPACE NAME
-- WHY: When using workspaces, you need to know which one you're in
local workspace = window:active_workspace()
local ws_name = workspace:match("([^/]+)$") or workspace -- Extract basename
table.insert(cells, { Foreground = { Color = "#89b4fa" } }) -- Blue
table.insert(cells, { Text = " " .. ws_name })
-- CURRENT WORKING DIRECTORY
-- WHY: Quick reference without running pwd
local cwd = pane:get_current_working_dir()
if cwd then
local home = os.getenv("HOME") or ""
local path = cwd.file_path:gsub(home, "~")
-- Truncate very long paths to keep status bar readable
if #path > 40 then
path = "…" .. path:sub(-39) -- Show last 39 chars with ellipsis
end
table.insert(cells, { Foreground = { Color = "#6c7086" } }) -- Overlay0 (gray)
table.insert(cells, { Text = " " .. path })
end
-- TIME
-- WHY: Convenient clock without needing to look away from terminal
table.insert(cells, { Foreground = { Color = "#585b70" } }) -- Surface2 (dark gray)
table.insert(cells, { Text = " " .. wezterm.strftime("%H:%M") .. " " })
-- Apply the formatted status bar
window:set_right_status(wezterm.format(cells))
end)
-- ============================================================================
-- RETURN THE CONFIGURATION
-- ============================================================================
-- This is required - WezTerm loads this file and uses the returned table
return config
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment