zac (zsh-appearance-control)
March 22, 2026 · View on GitHub
zsh-appearance-control makes switching between light and dark terminal themes feel smooth, and helps keep your shells and tools in sync with your OS appearance.
It gives you two things:
- a shared, always-updated “dark or light?” flag (tmux option or cache file)
- a safe way to nudge running shells to resync when that flag changes
If you use tmux, it integrates especially smoothly: tmux can hold the shared flag as @dark_appearance, so every pane sees the same truth. If you do not use tmux, the plugin uses a small cache file instead.
This README also includes minimal, working examples of what that enables:
- tmux theme switching — see tmux
- Neovim auto theme switching (by watching the appearance file) — see Neovim
- Emacs auto theme switching (by watching the appearance file) — see Emacs
It’s designed to be calm and predictable:
- it does not constantly poll your system
- it does not run heavy commands every time your prompt is drawn
- it updates when something tells it “the appearance changed”
Is this plugin for you?
This plugin is for people who like their terminal to feel coherent.
If you switch your system between light and dark mode, you probably want more than just the terminal window to change: you want tmux to pick a matching theme, you want your prompt to adjust, and you may want tools like fzf to use different colors.
The hard part is that your shell doesn’t automatically hear about OS appearance changes. So most setups end up either polling (slow and annoying) or having a handful of custom scripts that don’t quite agree.
zsh-appearance-control gives you a clean place to store “dark or light?” and a simple way to react when it changes.
You can wire it to your terminal’s appearance hooks (for example WezTerm), or you can change appearance manually with zac.
How it works (in plain words)
Your terminal (or a tiny helper you run) notices when the system appearance changes. It then nudges your shells.
Inside each shell, the plugin keeps a small cached value (0 or 1) and runs your callback (optional) so you can adjust your shell environment.
There are two “places” the plugin can read from:
- If you are inside tmux, tmux option
@dark_appearanceis the source of truth. - If you are not inside tmux, a small cache file is used (in your user cache directory).
Install
However you install it, set any ZAC_* environment variables before the plugin is loaded (usually in your .zshrc, above the plugin line).
Oh My Zsh
Clone this repo into your Oh My Zsh custom plugins directory:
git clone https://github.com/alberti42/zsh-appearance-control.git \
"$ZSH_CUSTOM/plugins/zsh-appearance-control"
Then add it to your plugins list in .zshrc:
plugins=(... zsh-appearance-control)
zinit
zinit lucid wait light-mode for \
wait'0' \
atinit"export ZAC_IMMEDIATE_CALLBACK_FNC=my_immediate_callback" \
atload'zac sync && my_immediate_callback "$REPLY"' \
path/to/zsh-appearance-control
A few things worth noting
wait'0'defers loading until after the first prompt, so the plugin does not slow down shell startup. If your prompt theme needs the appearance applied before the very first draw, load the plugin earlier (removewaitentirely or use a lower turbo stage).atinitsetsZAC_IMMEDIATE_CALLBACK_FNCbefore the plugin is sourced.atloadrunszac synconce after the plugin loads to read the current appearance, then calls your immediate callback directly to apply it to the current shell.zac syncstores the result in$REPLY, so passing it straight to the callback avoids a second query.ZAC_IO_CMDis read bybin/appearance-dispatch, not by the plugin. Set it viaenvin your watcher (e.g. WezTerm) — not here. See Connecting it to your terminal.
For my_immediate_callback, see If you want your shell to react.
DIY (no plugin manager)
Clone the repo anywhere you like, then source the entry file from .zshrc:
source "/path/to/zsh-appearance-control/zsh-appearance-control.plugin.zsh"
After installing, restart your terminal (or start a new shell).
Everyday use
Most people don’t interact with the plugin directly. You either:
- let your terminal handle appearance changes (recommended), or
- manually switch with
zac.
The zac command
The plugin provides a zac command:
zac statusprints1for dark and0for light. If the value is unknown, it still prints0and exits with a non-zero status.zac syncrefreshes the cached value from the current source of truth.zac dark,zac light,zac toggleask your OS to switch appearance, and then update the current shell immediately.
On macOS, switching is done via the system appearance setting. On Linux, GNOME is supported via the GNOME setting.
If you want your shell to react
The plugin gives you three hooks. The right one depends on what you are trying to do.
| Hook | Runs | Use for |
|---|---|---|
ZAC_IO_CMD | Once, in the dispatcher, before shells are signaled | Writing config files to disk |
ZAC_IMMEDIATE_CALLBACK_FNC | In every shell, immediately on signal | Updating env vars and shell settings |
ZAC_DEFERRED_CALLBACK_FNC | In every shell, at the next prompt | Prompt redraws, anything heavier |
ZAC_IO_CMD is an executable path, not a shell function. The dispatcher runs it once per appearance change (under a lock, idempotent) before signaling any shell. Use it for anything that writes to files on disk: tool config files, theme files, JSON settings. By the time shells receive the signal, the files are already updated. If it exits with a non-zero status the entire pipeline is aborted — no shells are signaled.
Must be a single executable path. If you need to pass arguments or set environment variables, write a small wrapper script. Use #!/bin/zsh (without -f) as the shebang so that your .zshenv is sourced automatically and your usual environment variables are available.
ZAC_IO_CMD is read by bin/appearance-dispatch, not by the plugin. If your watcher is an external process that does not inherit your shell environment (such as WezTerm), you must pass ZAC_IO_CMD explicitly via env when invoking the dispatcher — exporting it in your .zshrc or plugin config has no effect on that invocation. See the WezTerm example below.
export ZAC_IO_CMD=/path/to/your/io-script
ZAC_IMMEDIATE_CALLBACK_FNC is a shell function called directly inside the signal handler in every shell, before the next prompt redraws. Use it for lightweight, instant in-shell updates.
Allowed: export, typeset, zstyle, and source of files that only contain variable assignments.
Not allowed: I/O, subshells, pipes, or external commands — these can hang or corrupt shell state inside a signal handler.
my_immediate_callback() {
local is_dark=\$1
if (( is_dark )); then
export FZF_DEFAULT_OPTS='--color=bg+:#1f2430,fg:#c8d3f5,hl:#82aaff'
else
export FZF_DEFAULT_OPTS='--color=bg+:#f2f2f2,fg:#2d2a2e,hl:#005f87'
fi
}
export ZAC_IMMEDIATE_CALLBACK_FNC=my_immediate_callback
ZAC_DEFERRED_CALLBACK_FNC is a shell function called at the next precmd/preexec boundary in every shell. Safe for anything: prompt redraws, plugin reconfiguration, external tool calls. Use it when you need to do something heavier in-shell that can wait until the next prompt.
my_deferred_callback() {
local is_dark=\$1
# safe to call external tools, redraw prompt, etc.
}
export ZAC_DEFERRED_CALLBACK_FNC=my_deferred_callback
Connecting it to your terminal (the “watcher”)
The plugin does not try to guess when your system appearance changes. Instead, you (or your terminal) call a tiny helper script when the appearance changes.
This repo ships a standalone dispatcher: bin/appearance-dispatch.
Recommended: dispatch
bin/appearance-dispatch dispatch <on|off|1|0|true|false>
This is the unified pipeline. On each call it:
- Runs
ZAC_IO_CMDonce if the appearance changed (skipped if already applied — idempotent). - Writes both ground truths: tmux
@dark_appearanceand the cache file. - Signals all registered shells with
USR1.
If ZAC_IO_CMD fails, the entire pipeline is aborted — no shells are signaled. This ensures your tool config files and your shells are always in sync.
To pass ZAC_IO_CMD from a watcher that does not inherit your shell environment (such as WezTerm), set it via env:
env ZAC_IO_CMD=/path/to/your/io-script bin/appearance-dispatch dispatch 1
Legacy: tmux and cache
The older two-call pattern is still supported for backward compatibility:
bin/appearance-dispatch tmux <on|off|1|0|true|false>
bin/appearance-dispatch cache <on|off|1|0|true|false>
Both now call the same unified pipeline internally, so they behave identically to dispatch.
The dispatcher only signals shell processes that have opted in (shells that loaded this plugin), so it avoids accidentally sending signals to unrelated shells.
TL;DR: cache updates (in-place vs atomic)
The appearance cache file is updated “in place” by default. In plain words: we overwrite the contents of the same file, so it stays the same file on disk. This keeps file watchers simple.
If you set ZAC_CACHE_ATOMIC=1, updates become “atomic”: the dispatcher writes a temporary file and then swaps it into place. This is more crash-proof, but given the tiny size of the 0/1 flag a read/write race is extremely unlikely. Choose atomic mode if you want peace of mind that shell scripts always read a valid value.
There is a tradeoff: because atomic mode replaces the file each time (the inode changes), tools that watch files (like editor configs) must watch the directory rather than the file itself.
Debugging
If you want to see what the plugin is doing, you can turn on debug logging:
export ZAC_DEBUG=1
Then, in one terminal:
zac debug console
This shows a live log stream while you trigger appearance changes.
Optional extra: ssh-tmux
If enabled (default), this plugin also provides ssh-tmux.
It works like ssh (same arguments), but it automatically attaches to a remote tmux session and makes sure the session has @dark_appearance set.
You can disable it by setting:
export ZAC_ENABLE_SSH_TMUX=0
You can customize the remote tmux session name (default: main):
export ZAC_SSH_TMUX_SESSION=main
Configuration
Configuration is done with environment variables (set them before the plugin is loaded):
ZAC_IMMEDIATE_CALLBACK_FNCname of a function called inside the signal handler (env var assignments only)ZAC_DEFERRED_CALLBACK_FNCname of a function called at the next precmd/preexec (safe for anything)ZAC_IO_CMDpath to an executable run once per appearance change by the dispatcher (heavy I/O)ZAC_CACHE_DIRwhere to store the non-tmux cache file and pid registryZAC_LINUX_DESKTOPset tognometo force GNOME support, ornoneto disable itZAC_DEBUGset to1to enable debug loggingZAC_ENABLE_SSH_TMUXset to0to disable thessh-tmuxextra
ZAC_CALLBACK_FNC is accepted as a legacy alias for ZAC_DEFERRED_CALLBACK_FNC.
A note on watchers
Different terminals and desktops offer different ways to react to appearance changes. WezTerm is a great option because it can run a command when the appearance changes.
If your terminal does not offer hooks, you can still use this plugin:
- switch manually with
zac dark/light/toggle, or - write a small watcher script/service that calls
bin/appearance-dispatchwhen your system appearance changes.
Example: WezTerm appearance hook
WezTerm can run a command when the system appearance changes. Here is a sketch you can adapt:
local wezterm = require 'wezterm'
local home = os.getenv('HOME')
local function scheme_for_appearance(appearance)
local zac_dispatcher = home .. '/path/to/zsh-appearance-control/bin/appearance-dispatch'
-- WezTerm is a GUI app and does not inherit the login-shell PATH.
-- Tools called by appearance-dispatch (such as tmux) may not be found
-- unless you explicitly extend PATH here.
-- Common directories to add:
-- macOS Homebrew (Apple Silicon): /opt/homebrew/bin
-- macOS Homebrew (Intel): /usr/local/bin
-- zinit polaris: home .. '/.local/share/zinit/polaris/bin'
local tmux_dir = '/opt/homebrew/bin' -- adjust to match where tmux lives on your system
local env_path = tmux_dir .. ':' .. os.getenv('PATH')
local is_dark = appearance:find('Dark') ~= nil
local dark = is_dark and '1' or '0'
-- Single dispatch call: writes both ground truths and signals all shells.
-- Pass ZAC_IO_CMD explicitly — WezTerm does not inherit your shell environment,
-- so env vars from .zshenv are not available here.
-- Omit the ZAC_IO_CMD line if you have no heavy I/O to run.
wezterm.run_child_process({
'env',
'PATH=' .. env_path,
'ZAC_IO_CMD=' .. home .. '/path/to/your/io-script', -- optional
zac_dispatcher, 'dispatch', dark,
})
return is_dark and 'My Dark Scheme' or 'My Light Scheme'
end
wezterm.on('window-config-reloaded', function(window, pane)
local overrides = window:get_config_overrides() or {}
overrides.color_scheme = scheme_for_appearance(window:get_appearance())
window:set_config_overrides(overrides)
end)
tmux: theme switching with @dark_appearance
tmux is a great place to keep a single “appearance flag” that all panes can share.
When your OS switches between light and dark mode, a watcher (for example WezTerm) can update tmux option @dark_appearance.
From there, your tmux theme can instantly switch palettes, and every shell inside tmux can sync its own environment on the next prompt.
The key idea is simple:
- keep a boolean option in tmux:
@dark_appearance(1for dark,0for light) - define your theme colors in terms of that flag
Here is a tiny sketch:
# ~/.tmux.conf
source-file "$HOME/.config/tmux/catppuccin.conf"
# ~/.config/tmux/catppuccin.conf
set-option -g @dark_appearance 0
This repo includes a complete example you can copy and adapt:
examples/tmux/catppuccin.conf
Neovim: switch running instances on change
If you want Neovim to react to the same appearance changes as your shells, a great approach is to watch a file and react when it changes.
Henrik Sommerfeld has a nice write-up of that file-watching technique for Neovim:
zsh-appearance-control helps by providing a shared, simple “dark or light?” flag that other tools can consume.
Instead of inventing your own ~/.theme convention, you can reuse the plugin’s cache file:
ZAC_CACHE_DIR/appearance(default:~/.cache/zac/appearance)
That file contains a single character:
1for dark0for light
Your watcher updates it by calling bin/appearance-dispatch dispatch ..., and then your shells (and Neovim) can react.
Here is a minimal sketch (inspired by the same mechanics Henrik describes) that watches the file and switches Neovim’s background:
This uses Neovim’s built-in file watching (libuv via vim.uv), so you do not need to install any extra Neovim plugin.
If you are on an older Neovim version that does not have vim.uv, try replacing it with vim.loop.
Where do I put this?
If you are new to Neovim config, a simple way to try this is:
- Create a file named
auto-color-scheme.luain your Neovim config directory.
On most systems, that directory is:
~/.config/nvim/
So the full path would be:
~/.config/nvim/auto-color-scheme.lua
-
Paste the Lua code below into that file.
-
In your
init.lua, load it with:
-- Load auto-color-scheme:
-- watches ZAC_CACHE_DIR/appearance (0/1) and switches colorscheme live.
dofile(vim.fn.stdpath("config") .. "/auto-color-scheme.lua")
local uv = vim.uv
local function zac_appearance_file()
local cache = os.getenv("ZAC_CACHE_DIR")
if not cache or cache == "" then
cache = (os.getenv("XDG_CACHE_HOME") or (os.getenv("HOME") .. "/.cache")) .. "/zac"
end
return cache .. "/appearance"
end
local function read_mode(path)
local f = io.open(path, "r")
if not f then
return "0"
end
local line = f:read("*line") or "0"
f:close()
return line
end
local function apply_mode(mode)
if mode == '1' then
vim.o.background = 'dark'
pcall(vim.cmd.colorscheme, 'catppuccin-macchiato')
-- vim.notify("Switching to dark mode 🌘")
else
vim.o.background = 'light'
pcall(vim.cmd.colorscheme, 'catppuccin-frappe')
-- vim.notify("Switching to light mode 🌖")
end
end
local path = zac_appearance_file()
-- Apply once on startup.
vim.schedule(function()
apply_mode(read_mode(path))
end)
-- Watch for changes.
local handle = uv.new_fs_event()
if handle then
uv.fs_event_start(handle, path, {}, function(err)
if err then
return
end
vim.schedule(function()
apply_mode(read_mode(path))
end)
end)
end
Emacs: auto theme switching
This repo ships editors/emacs/zac-theme-autodetection.el (also available in release artifacts). It watches the zsh-appearance-control appearance file and calls a user-supplied callback whenever the OS appearance changes. It needs no extra packages beyond what Emacs provides.
It handles:
- initial application at load time
- daemon/emacsclient support (via
after-make-frame-functions) - live file watching (via Emacs's built-in
file-notify), with a graceful fallback message if file notifications are not supported
Setup
Add the module's directory to your load-path, then set zac-load-theme-callback before loading the module so it is available for the initial application:
(add-to-list 'load-path "/path/to/editors/emacs")
;; Set callback before loading the module.
;; It receives :light or :dark.
(setq zac-load-theme-callback
(lambda (appearance)
(load-theme (if (eq appearance :light)
'modus-operandi
'modus-vivendi-tinted) t)))
;; Load the module. The watcher starts automatically.
(require 'zac-theme-autodetection)
modus-operandi (light) and modus-vivendi-tinted (dark) are built into Emacs 28+, so no extra package is needed. Swap them for any other theme theme you prefer.
Optional: harmonizing other faces after theme changes (Emacs 29+)
Some packages do not automatically pick up face changes when a theme switches. In Emacs 29+, you can hook into enable-theme-functions to fix up any faces immediately after every load-theme call:
(defun my/harmonize-theme (&rest _)
"Re-apply custom face settings after every theme change.
Add any face customizations that your packages need here."
;; Example: blend git-gutter background with the line-number column.
;; (when (facep 'git-gutter:added)
;; (let ((bg (face-background 'line-number nil t)))
;; (dolist (face '(git-gutter:added git-gutter:modified
;; git-gutter:deleted git-gutter:unchanged))
;; (when (facep face)
;; (set-face-background face bg)))))
)
;; Fire on every theme change (Emacs 29+).
(add-hook 'enable-theme-functions #'my/harmonize-theme)
Define and register this hook before loading zac-theme-autodetection.el so it also fires on the initial theme application.
Now, whenever your watcher updates ZAC_CACHE_DIR/appearance (via bin/appearance-dispatch dispatch ...), Emacs will follow along.
Author
- Author: Andrea Alberti
- GitHub Profile: alberti42
- Donations:
Feel free to contribute to the development of this plugin or report any issues in the GitHub repository.
License
This project is licensed under the MIT License. See the LICENSE file for details.