{ pkgs, lib, config, ... }: let cfg = config.mods.terminal; color = config.lib.stylix.colors.withHashtag; # Shell to use inside wezterm shell = if cfg.nushell.enable then lib.getExe pkgs.nushell else if cfg.zsh.enable then lib.getExe pkgs.zsh else null; # ── Lua codegen helpers ────────────────────────────────────────────────────── luaKey = { key, mods ? null, action, indent ? " ", }: let modsStr = lib.optionalString (mods != null) ", mods = \"${mods}\""; in "${indent}{ key = \"${key}\"${modsStr}, action = ${action} },\n"; # vi directions: key, arrow alias, wezterm direction name viDirs = [ { key = "h"; arrow = "LeftArrow"; dir = "Left"; } { key = "j"; arrow = "DownArrow"; dir = "Down"; } { key = "k"; arrow = "UpArrow"; dir = "Up"; } { key = "l"; arrow = "RightArrow"; dir = "Right"; } ]; # hjkl + arrow equivalents; mkAction :: dir -> lua-action-string viDirKeys = { mods ? null, indent ? " ", mkAction, }: lib.concatMapStrings (d: luaKey { inherit mods indent; key = d.key; action = mkAction d.dir; } + luaKey { inherit mods indent; key = d.arrow; action = mkAction d.dir; }) viDirs; # 1-9 tab-jump bindings tabJumpKeys = { mods ? null, indent ? " ", }: lib.concatStrings (builtins.genList (i: luaKey { inherit mods indent; key = toString (i + 1); action = "act.ActivateTab(${toString i})"; }) 9); # hjkl + arrows for AdjustPaneSize at a given step resizeDirKeys = { step, indent ? " ", }: lib.concatMapStrings (d: luaKey { inherit indent; key = d.key; action = "act.AdjustPaneSize({ \"${d.dir}\", ${toString step} })"; } + luaKey { inherit indent; key = d.arrow; action = "act.AdjustPaneSize({ \"${d.dir}\", ${toString step} })"; }) viDirs; # Uppercase HJKL fine-tune resize resizeShiftKeys = { step, indent ? " ", }: lib.concatMapStrings (d: luaKey { inherit indent; key = lib.toUpper d.key; action = "act.AdjustPaneSize({ \"${d.dir}\", ${toString step} })"; }) viDirs; # Wrap a lua action string so it also pops the key table (return to locked) andPop = action: "act.Multiple({ ${action}, act.PopKeyTable })"; # Standard exit bindings, given the mode's own pop key exitKeys = { selfKey, indent ? " ", }: lib.concatStrings [ (luaKey { inherit indent; key = selfKey; action = "act.PopKeyTable"; }) (luaKey { inherit indent; key = "Escape"; action = "act.PopKeyTable"; }) (luaKey { inherit indent; key = "Enter"; action = "act.PopKeyTable"; }) (luaKey { inherit indent; key = "Space"; mods = "ALT"; action = "act.PopKeyTable"; }) ]; # ── Status bar ─────────────────────────────────────────────────────────────── # Per-mode accent colour (base16) and hint string shown in the status bar # Hints use nerd font glyphs: separators, icons, key labels modes = { locked = { fg = color.base03; hint = "⌥󱁐:󰌌 ⌥hl:󰹳 ^ud:󰹹 ⌥1-9:󰓩 ⌥n:󰤻 ⌥s:󱇳 ⌥w:󰅙 ⌥t:󰓩 ⌥p:󱃔"; }; normal = { fg = color.base0D; hint = "t:󰐕 x:󰅙 r:󰩨 c:󰆏 s:󰍉 esc:󱊷"; }; resize = { fg = color.base0A; hint = "hjkl:󰁌 HJKL:󰁎 esc:󱊷"; }; copy_mode = { fg = color.base0E; hint = "hjkl:󰹳 v:󰒆 V:󰒇 ^v:󰒈 y:󰆏 /:󰍉 n/p:󰁹 esc:󱊷"; }; search_mode = { fg = color.base08; hint = "type:󰈙 ^n/^p:󰁹 ^r:󰑓 ↵/esc:󰆐"; }; }; # Lua table literal mapping mode name -> { fg, hint } modeTableEntries = lib.concatStringsSep "\n " (lib.mapAttrsToList (name: m: '' ["${name}"] = { fg = "${m.fg}", hint = "${m.hint}" }, '') modes); in { options.mods.terminal.wezterm.enable = lib.mkEnableOption "enables wezterm"; config = lib.mkIf cfg.wezterm.enable { programs.wezterm = { enable = true; extraConfig = '' local wezterm = require("wezterm") local act = wezterm.action -- ─── Helpers ─────────────────────────────────────────────────────────────── -- move_focus_or_tab: mirrors Zellij's MoveFocusOrTab. -- Move focus in direction; if at the edge, switch to the adjacent tab. local function move_focus_or_tab(direction) return wezterm.action_callback(function(window, pane) local neighbour = pane:tab():get_pane_direction(direction) if neighbour ~= nil then window:perform_action(act.ActivatePaneDirection(direction), pane) else if direction == "Left" then window:perform_action(act.ActivateTabRelative(-1), pane) elseif direction == "Right" then window:perform_action(act.ActivateTabRelative(1), pane) end end end) end -- scroll_or_passthrough: Ctrl+key scrolls at the shell prompt but passes -- the key through when a program has taken the alt screen (vim, less, fzf…). local function scroll_or_passthrough(key, pages) return wezterm.action_callback(function(window, pane) if pane:is_alt_screen_active() then window:perform_action(act.SendKey({ key = key, mods = "CTRL" }), pane) else window:perform_action(act.ScrollByPage(pages), pane) end end) end -- tab_is_zoomed: true if any pane in the current tab is zoomed. -- Used by move_vertical to decide whether to carry zoom forward. local function tab_is_zoomed(pane) for _, info in ipairs(pane:tab():panes_with_info()) do if info.is_zoomed then return true end end return false end -- move_vertical: move focus Up/Down. -- If any pane in the tab is currently zoomed (zoom-mode is "on" for this -- tab), zoom the destination pane too — stacked-panel behaviour. -- Otherwise just move focus plainly. local function move_vertical(direction) return wezterm.action_callback(function(window, pane) local neighbour = pane:tab():get_pane_direction(direction) if neighbour == nil then return end if tab_is_zoomed(pane) then -- unzoom_on_switch_pane unzooms the current pane when we move; -- then SetPaneZoomState(true) zooms the newly active pane. window:perform_action( act.Multiple({ act.ActivatePaneDirection(direction), act.SetPaneZoomState(true), }), pane ) else window:perform_action(act.ActivatePaneDirection(direction), pane) end end) end -- ─── Status bar ──────────────────────────────────────────────────────────── -- Per-mode accent colour and hint text, generated from Nix/stylix palette local mode_info = { ${modeTableEntries} } local bg_bar = "${color.base01}" local fg_text = "${color.base05}" local fg_dim = "${color.base03}" local bg_main = "${color.base00}" -- Render hints with the key in the mode accent colour and the icon dimmed. -- Each hint token is "key:icon"; we colour them individually. local function render_hints(hint, accent) local cells = {} for token in hint:gmatch("%S+") do local key, icon = token:match("^(.-):(.)$") if key and icon then table.insert(cells, { Background = { Color = bg_bar } }) table.insert(cells, { Foreground = { Color = accent } }) table.insert(cells, { Text = key }) table.insert(cells, { Foreground = { Color = fg_text } }) table.insert(cells, { Text = ":" .. icon .. " " }) else table.insert(cells, { Background = { Color = bg_bar } }) table.insert(cells, { Foreground = { Color = fg_dim } }) table.insert(cells, { Text = token .. " " }) end end return cells end wezterm.on("format-window-title", function(tab, _pane, _tabs, _panes, _config) local ok, win = pcall(function() return wezterm.mux.get_window(tab.window_id) end) if ok and win then local ws = win:get_workspace() if ws ~= "default" then return ws .. " — " .. tab.active_pane.title end end return tab.active_pane.title end) -- format-tab-title: show "rank/total title" when the active tab is in -- stacked (zoom) mode; otherwise show plain " title ". -- Returning a cell table gives us explicit padding and colours; -- returning a plain string loses the retro tab bar's built-in spacing. local tab_bg_active = "${color.base0D}" local tab_fg_active = "${color.base00}" local tab_bg_inactive = "${color.base01}" local tab_fg_inactive = "${color.base03}" wezterm.on("format-tab-title", function(tab, _tabs, panes, _config, _hover, _max_width) local title = (tab.tab_title and tab.tab_title ~= "") and tab.tab_title or tab.active_pane.title local bg = tab.is_active and tab_bg_active or tab_bg_inactive local fg = tab.is_active and tab_fg_active or tab_fg_inactive -- Only compute stack position for the active tab. -- The `panes` event parameter only contains the active pane snapshot -- and lacks `top`, so we use wezterm.mux.get_tab():panes_with_info() -- which returns full PaneInformation including top/is_zoomed/is_active. if tab.is_active then local mux_tab = wezterm.mux.get_tab(tab.tab_id) if mux_tab then local infos = mux_tab:panes_with_info() table.sort(infos, function(a, b) return a.top < b.top end) local any_zoomed = false local active_rank, total = 0, 0 for _, p in ipairs(infos) do total = total + 1 if p.is_active then active_rank = total end if p.is_zoomed then any_zoomed = true end end if any_zoomed and total > 1 and active_rank > 0 then title = active_rank .. "/" .. total .. " " .. title end end end return { { Background = { Color = bg } }, { Foreground = { Color = fg } }, { Text = " " .. title .. " " }, } end) wezterm.on("update-status", function(window, _pane) local kt = window:active_key_table() local mode = kt or "locked" local info = mode_info[mode] or mode_info["locked"] window:set_left_status("") -- Right: coloured hints then mode badge local cells = { { Text = " " } } for _, c in ipairs(render_hints(info.hint, info.fg)) do table.insert(cells, c) end table.insert(cells, "ResetAttributes") table.insert(cells, { Background = { Color = info.fg } }) table.insert(cells, { Foreground = { Color = bg_bar } }) table.insert(cells, { Text = "" }) table.insert(cells, { Foreground = { Color = bg_main } }) table.insert(cells, { Attribute = { Intensity = "Bold" } }) table.insert(cells, { Text = " " .. mode:upper() .. " " }) table.insert(cells, "ResetAttributes") window:set_right_status(wezterm.format(cells)) end) -- ─── Appearance ──────────────────────────────────────────────────────────── local config = wezterm.config_builder() -- Stylix sets the color scheme via its wezterm integration; -- this is a fallback in case it is not active. config.color_scheme = "Synthwave (Gogh)" config.font = wezterm.font("CommitMono Nerd Font") config.font_size = 12.0 config.window_padding = { left = 6, right = 6, top = 6, bottom = 6 } config.default_cursor_style = "BlinkingBar" config.hide_tab_bar_if_only_one_tab = false config.use_fancy_tab_bar = false config.tab_max_width = 24 config.colors = { tab_bar = { background = "${color.base01}", active_tab = { bg_color = "${color.base0D}", fg_color = "${color.base00}", intensity = "Bold" }, inactive_tab = { bg_color = "${color.base01}", fg_color = "${color.base03}" }, inactive_tab_hover = { bg_color = "${color.base02}", fg_color = "${color.base04}" }, new_tab = { bg_color = "${color.base01}", fg_color = "${color.base03}" }, new_tab_hover = { bg_color = "${color.base02}", fg_color = "${color.base04}" }, }, } config.tab_bar_at_bottom = false -- ─── SSH Domains ─────────────────────────────────────────────────────────── config.ssh_domains = { { name = "muho", remote_address = "muho", username = "muon", }, } -- ─── Shell ───────────────────────────────────────────────────────────────── ${lib.optionalString (shell != null) '' config.default_prog = { "${shell}" } ''} -- ─── Scrollback ──────────────────────────────────────────────────────────── config.scrollback_lines = 10000 -- ─── Stacked panes ───────────────────────────────────────────────────────── -- When switching away from a zoomed pane, unzoom it automatically. -- Paired with stack_focus (Alt+j/k) this gives Zellij-style stacked panels. config.unzoom_on_switch_pane = true -- ─── Key bindings ────────────────────────────────────────────────────────── -- -- Default mode = "locked" (all keys pass through to the shell). -- Alt+Space enters "normal" mode. -- From normal: t→new tab x→close tab p/n→split r→resize mode -- Esc / Enter / Alt+Space → back to locked from any mode. config.disable_default_key_bindings = true config.keys = { -- Alt+h/l: move focus or switch tab at edge (Zellij MoveFocusOrTab) -- Alt+j/k: initial bindings (overridden below by stacked-focus variants) ${viDirKeys { mods = "ALT"; indent = " "; mkAction = d: if d == "Left" || d == "Right" then "move_focus_or_tab(\"${d}\")" else "act.ActivatePaneDirection(\"${d}\")"; }} -- Alt+1-9: jump to tab by number ${tabJumpKeys { mods = "ALT"; indent = " "; }} -- Alt+n: new pane (split right) { key = "n", mods = "ALT", action = act.SplitHorizontal({ domain = "CurrentPaneDomain" }) }, -- Alt+s: split below, then zoom the new pane so stacked mode starts. -- Two separate perform_action calls: the split runs first and focuses -- the new pane, then the second call zooms window:active_pane() which -- is now the new pane (not the original `pane` argument). { key = "s", mods = "ALT", action = wezterm.action_callback(function(window, pane) window:perform_action(act.SplitVertical({ domain = "CurrentPaneDomain" }), pane) window:perform_action(act.SetPaneZoomState(true), window:active_pane()) end) }, -- Alt+j/k: move_vertical — plain focus move normally, stacked zoom -- when any pane in the tab is already zoomed (mirrors Alt+f state). { key = "j", mods = "ALT", action = move_vertical("Down") }, { key = "k", mods = "ALT", action = move_vertical("Up") }, { key = "DownArrow", mods = "ALT", action = move_vertical("Down") }, { key = "UpArrow", mods = "ALT", action = move_vertical("Up") }, -- Alt+f: toggle zoom { key = "f", mods = "ALT", action = act.TogglePaneZoomState }, -- Alt+[/]: rotate panes { key = "[", mods = "ALT", action = act.RotatePanes("CounterClockwise") }, { key = "]", mods = "ALT", action = act.RotatePanes("Clockwise") }, -- Alt++/-/=: font size { key = "+", mods = "ALT", action = act.IncreaseFontSize }, { key = "-", mods = "ALT", action = act.DecreaseFontSize }, { key = "=", mods = "ALT", action = act.ResetFontSize }, -- Alt+i/o: reorder tabs { key = "i", mods = "ALT", action = act.MoveTabRelative(-1) }, { key = "o", mods = "ALT", action = act.MoveTabRelative(1) }, -- Alt+w: close current pane; if tab was in stacked (zoom) mode, re-zoom -- the new active pane so zoom mode persists across close. -- confirm=false because the callback must run synchronously after close; -- a confirm dialog would make the zoom fire before the user answers. -- Guard: only re-zoom when there will still be panes left after close. { key = "w", mods = "ALT", action = wezterm.action_callback(function(window, pane) local panes = pane:tab():panes() local was_zoomed = tab_is_zoomed(pane) window:perform_action(act.CloseCurrentPane({ confirm = false }), pane) if was_zoomed and #panes > 1 then window:perform_action(act.SetPaneZoomState(true), window:active_pane()) end end) }, -- Alt+p: command palette { key = "p", mods = "ALT", action = act.ActivateCommandPalette }, -- Alt+t: tab navigator (searchable tab list) { key = "t", mods = "ALT", action = act.ShowTabNavigator }, -- Alt+q: quit { key = "q", mods = "ALT", action = act.QuitApplication }, -- Ctrl+U/D: scroll half page when at the shell prompt; pass through -- to the running program when it is using the alt screen (vim, less, -- fzf, etc. switch to the alt screen and need the raw key). { key = "u", mods = "CTRL", action = scroll_or_passthrough("u", -0.5) }, { key = "d", mods = "CTRL", action = scroll_or_passthrough("d", 0.5) }, -- Copy / paste { key = "c", mods = "CTRL|SHIFT", action = act.CopyTo("Clipboard") }, { key = "v", mods = "CTRL|SHIFT", action = act.PasteFrom("Clipboard") }, -- Enter normal mode { key = "Space", mods = "ALT", action = act.ActivateKeyTable({ name = "normal", one_shot = false }) }, } config.key_tables = { -- ── NORMAL ───────────────────────────────────────────────────────────── normal = { { key = "t", action = ${andPop "act.SpawnTab(\"CurrentPaneDomain\")"} }, { key = "x", action = ${andPop "act.CloseCurrentTab({ confirm = true })"} }, { key = "r", action = act.ActivateKeyTable({ name = "resize", one_shot = false }) }, { key = "c", action = ${andPop "act.ActivateCopyMode"} }, { key = "s", action = ${andPop "act.Search({ CaseSensitiveString = \"\" })"} }, ${exitKeys {selfKey = "Escape";}} }, -- ── RESIZE ───────────────────────────────────────────────────────────── resize = { ${resizeDirKeys {step = 5;}}${resizeShiftKeys {step = 1;}} { key = "+", action = act.AdjustPaneSize({ "Right", 5 }) }, { key = "-", action = act.AdjustPaneSize({ "Left", 5 }) }, { key = "=", action = act.AdjustPaneSize({ "Right", 5 }) }, ${exitKeys {selfKey = "r";}} }, -- ── COPY MODE ────────────────────────────────────────────────────────── copy_mode = { -- movement { key = "h", action = act.CopyMode("MoveLeft") }, { key = "j", action = act.CopyMode("MoveDown") }, { key = "k", action = act.CopyMode("MoveUp") }, { key = "l", action = act.CopyMode("MoveRight") }, { key = "LeftArrow", action = act.CopyMode("MoveLeft") }, { key = "DownArrow", action = act.CopyMode("MoveDown") }, { key = "UpArrow", action = act.CopyMode("MoveUp") }, { key = "RightArrow", action = act.CopyMode("MoveRight") }, { key = "w", action = act.CopyMode("MoveForwardWord") }, { key = "b", action = act.CopyMode("MoveBackwardWord") }, { key = "e", action = act.CopyMode("MoveForwardWordEnd") }, { key = "0", action = act.CopyMode("MoveToStartOfLine") }, { key = "^", action = act.CopyMode("MoveToStartOfLineContent") }, { key = "$", action = act.CopyMode("MoveToEndOfLineContent") }, { key = "g", action = act.CopyMode("MoveToScrollbackTop") }, { key = "G", action = act.CopyMode("MoveToScrollbackBottom") }, { key = "Enter", action = act.CopyMode("MoveToStartOfNextLine") }, { key = "u", mods = "CTRL", action = act.CopyMode({ MoveByPage = -0.5 }) }, { key = "d", mods = "CTRL", action = act.CopyMode({ MoveByPage = 0.5 }) }, { key = "b", mods = "CTRL", action = act.CopyMode("PageUp") }, { key = "f", mods = "CTRL", action = act.CopyMode("PageDown") }, { key = "PageUp", action = act.CopyMode("PageUp") }, { key = "PageDown", action = act.CopyMode("PageDown") }, -- selection { key = "v", action = act.CopyMode({ SetSelectionMode = "Cell" }) }, { key = "V", action = act.CopyMode({ SetSelectionMode = "Line" }) }, { key = "v", mods = "CTRL", action = act.CopyMode({ SetSelectionMode = "Block" }) }, { key = "o", action = act.CopyMode("MoveToSelectionOtherEnd") }, { key = "O", action = act.CopyMode("MoveToSelectionOtherEndHoriz") }, -- yank and exit { key = "y", action = act.Multiple({ act.CopyTo("ClipboardAndPrimarySelection"), act.Multiple({ act.ScrollToBottom, act.CopyMode("Close") }), }) }, -- search within copy mode { key = "/", action = act.Search({ CaseSensitiveString = "" }) }, { key = "n", action = act.CopyMode("NextMatch") }, { key = "p", action = act.CopyMode("PriorMatch") }, -- exit { key = "q", action = act.Multiple({ act.ScrollToBottom, act.CopyMode("Close") }) }, { key = "Escape", action = act.Multiple({ act.ScrollToBottom, act.CopyMode("Close") }) }, { key = "c", mods = "CTRL", action = act.Multiple({ act.ScrollToBottom, act.CopyMode("Close") }) }, }, -- ── SEARCH MODE ──────────────────────────────────────────────────────── search_mode = { -- navigate matches { key = "Enter", action = act.CopyMode("AcceptPattern") }, { key = "n", mods = "CTRL", action = act.CopyMode("NextMatch") }, { key = "p", mods = "CTRL", action = act.CopyMode("PriorMatch") }, { key = "PageUp", action = act.CopyMode("PriorMatchPage") }, { key = "PageDown", action = act.CopyMode("NextMatchPage") }, { key = "UpArrow", action = act.CopyMode("PriorMatch") }, { key = "DownArrow", action = act.CopyMode("NextMatch") }, -- cycle match type (case-sensitive / insensitive / regex) { key = "r", mods = "CTRL", action = act.CopyMode("CycleMatchType") }, -- clear search input { key = "u", mods = "CTRL", action = act.CopyMode("ClearPattern") }, -- Escape: dismiss search bar, return to copy mode at current match { key = "Escape", action = act.CopyMode("AcceptPattern") }, }, } return config ''; }; }; }