psmux plugins are ports of the most popular tmux plugins, reimplemented in PowerShell so they run natively on Windows without bash, cygwin, or WSL. A psmux plugin works exactly the way a tmux plugin works: a script that calls the multiplexer's own CLI (set-option, bind-key, set-hook, run-shell) to configure it. The commands are identical to tmux; only the scripting language around them changed from bash to PowerShell. tmux themes that are plain option lines work in psmux verbatim.
Key facts
- Plugin manager: ppm (the tmux
tpmequivalent), declared withset -g @plugin '...'and loaded with a singlerunline, installed withPrefix + I. - Plugins live in
~/.psmux/plugins/<name>/(or$XDG_CONFIG_HOME/psmux/plugins/as a fallback). - Entry point:
<name>/<name>.ps1(PowerShell) or<name>/plugin.conf(pure tmux option lines). - psmux reads
@plugindeclarations itself during config parsing and sources the plugin'splugin.confsynchronously, so themes apply before the first frame (no flash). - tmux
.tmuxentry scripts are translated statically when referenced from a configrunline; full bash plugins can also be executed through Git Bash. - Browse plugins: psmux-plugins. Install with a TUI: Tmux Plugin Panel.
Related: Configuration 路 Key Bindings 路 Scripting 路 Dev environment layouts
Browse available plugins and themes: psmux-plugins
Install & manage plugins with a TUI: Tmux Plugin Panel, a terminal UI for browsing, installing, updating, and removing plugins and themes.
| Plugin | Description |
|---|---|
| ppm | Plugin manager (like tpm) |
| psmux-sensible | Sensible defaults for psmux |
| psmux-yank | Windows clipboard integration (retired: psmux copies to the Windows clipboard natively, see below) |
| psmux-resurrect | Save/restore sessions |
| psmux-continuum | Auto save/restore sessions (works with resurrect) |
| psmux-pain-control | Better pane navigation |
| psmux-vim-navigator | Prefix-less Ctrl-h/j/k/l pane navigation that passes through to vim, nvim and fzf |
| psmux-prefix-highlight | Prefix key indicator |
| psmux-battery | Battery status in status bar |
| psmux-cpu | CPU usage in status bar |
| psmux-net-speed | Network speed in status bar |
| psmux-git-status | Git branch and status in status bar |
| psmux-sidebar | File tree sidebar |
| psmux-logging | Log pane output to files |
| Theme | Description |
|---|---|
| Catppuccin | Soothing pastel theme (Latte, Frappe, Macchiato, Mocha) |
| Dracula | Dark theme with vibrant colors |
| Nord | Arctic, north bluish color palette |
| Tokyo Night | Clean dark theme inspired by Tokyo at night |
| Gruvbox | Retro groove color scheme |
| Everforest | Comfortable green based color scheme |
| Kanagawa | Dark theme inspired by Katsushika Hokusai |
| One Dark | Atom One Dark inspired theme |
| Rose Pine | Soho vibes for the terminal |
| Warm Burnout | Warm, low contrast theme (dark and light) |
# Install the plugin manager
git clone https://github.com/psmux/psmux-plugins.git "$env:TEMP\psmux-plugins"
Copy-Item "$env:TEMP\psmux-plugins\ppm" "$env:USERPROFILE\.psmux\plugins\ppm" -Recurse
Remove-Item "$env:TEMP\psmux-plugins" -Recurse -ForceThen add to your ~/.psmux.conf:
set -g @plugin 'psmux-plugins/ppm'
set -g @plugin 'psmux-plugins/psmux-sensible'
run '~/.psmux/plugins/ppm/ppm.ps1'Press Prefix + I inside psmux to install the declared plugins. Prefix + U updates them, Prefix + M removes plugins that are no longer declared.
The mechanism is the tmux one. A tmux plugin is a bash script that runs tmux set-option ... and tmux bind-key .... A psmux plugin is a PowerShell script that runs psmux set-option ... and psmux bind-key .... Everything after the binary name is the same text.
tmux plugin (bash): tmux set-option -g mouse on
psmux plugin (PowerShell): psmux set-option -g mouse on
The pieces of psmux that a plugin relies on, and where each lives in the source:
| Mechanism | What psmux does | Source |
|---|---|---|
set -g @plugin 'owner/name' |
The config parser stores the value as a user option, then looks for ~/.psmux/plugins/<name>/plugin.conf and sources it on the spot. If there is no plugin.conf it looks for <name>/<name>.ps1, extracts literal set/bind lines from it, and queues the script for execution at server start if it uses PowerShell variables. XDG paths under $XDG_CONFIG_HOME/psmux/plugins/ are tried as well. |
src/config.rs (@plugin branch in the set handler, parse_ps1_plugin_script) |
run '~/.psmux/plugins/ppm/ppm.ps1' in the config |
A config run/run-shell line spawns the command without blocking. ~ expands to your profile, and a ~/.psmux/plugins/ path is redirected to the XDG directory when only that one exists. The child gets PSMUX_TARGET_SESSION so its psmux calls reach the server that is starting up. |
src/config.rs (parse_run_shell), src/util.rs (expand_run_shell_path) |
Queued .ps1 plugin scripts |
After the config is loaded the server runs each queued script with pwsh -NoProfile -ExecutionPolicy Bypass -File <script> (falls back to powershell.exe when pwsh is missing) and services the scripts' set/show-options requests for up to 5 seconds before the UI starts. |
src/server/mod.rs (pending_plugin_scripts) |
run-shell from a key binding, hook, or the CLI |
The command is format expanded (#{pane_current_path} and friends) first. A .ps1 path runs as pwsh -NoProfile -ExecutionPolicy Bypass -File <path>, which is what makes paths with spaces safe. A pwsh, powershell, or cmd prefix is honoured as written. Anything else is wrapped in pwsh -NoProfile -Command (or powershell.exe, then cmd /c, whichever exists). Without -b the output is shown in a popup; with -b the command is fire and forget, and a spawn failure is reported in the status line instead of being swallowed. |
src/commands.rs (build_run_shell_command, resolve_run_shell, the run-shell arm) |
set -g @name value and show -gv @name |
Any option whose name starts with @ is a user option. Plugins use them for configuration (@resurrect-dir) and for output (@cpu_display). |
src/config.rs, src/format.rs |
#{@name} in a format |
User options expand inside status line formats, so a plugin can publish a value with set -g @cpu_display '42%' and a theme can place it with #{@cpu_display}. |
src/format.rs |
#(command) in the status line |
Supported. In the status bar it is expanded asynchronously and refreshed every status-interval seconds (default 15, floor 1 second), so a slow command never stalls rendering. In one-shot contexts such as display-message -p '#(hostname)' it runs synchronously and returns real output. |
src/format.rs (run_shell_command) |
if-shell 'cmd' 'then' 'else' and if-shell -F '#{...}' |
The condition runs in the same resolved shell as run-shell (so it is PowerShell syntax on Windows: if-shell 'Test-Path C:\tools' ...). -F evaluates a format instead of a process. |
src/commands.rs (if-shell arm) |
set-hook -g <hook> '<command>' |
Global hooks fire from the server loop. The hooks that really fire are the table in scripting.md, which is the canonical list: after-new-window, after-split-window, after-select-window, before-select-window, after-select-pane, after-select-layout, after-kill-pane, after-resize-pane, after-rename-window, after-rename-session, after-swap-pane, after-swap-window, after-rotate-window, after-break-pane, after-join-pane, after-respawn-pane, client-attached, client-detached, client-resized, client-session-changed, session-created, session-closed, pane-died, pane-exited, pane-focus-in, pane-focus-out, pane-mode-changed, pane-set-clipboard, window-linked, window-unlinked, window-closed, alert-activity, alert-silence, alert-bell. client-attached and session-created fire once at server start so plugins that hook them initialise on the first attach; session-created fires ONLY then, so a plugin has to set it from a config file. after-new-session, after-copy-mode, after-set-option, after-bind-key, after-unbind-key and after-source are accepted by set-hook and never fire (issue #691). |
src/help.rs (hooks_lines), src/server/mod.rs (the per request hook slot) |
display-message |
Plugins use display-message for toasts and display-message -p to read a format from a script; ppm uses display-message -d <ms> for its progress messages. |
src/commands.rs |
Two things in that table are worth repeating because they differ from tmux in a helpful way:
- Themes apply synchronously. tmux's tpm sources plugins asynchronously, so a themed status line can appear a moment after the default one. psmux sources a plugin's
plugin.confwhile it parses the line that declares it, so the first frame is already themed. - Every plugin process is told which server to talk to.
run-shell, hooks, and queued plugin scripts all receivePSMUX_TARGET_SESSION, so a plugin running under-Lnamespaces or a preview server never configures the wrong instance.
~/.psmux/plugins/
ppm/ # plugin manager
ppm.ps1 # entry point, loaded by the `run` line in your config
plugin.conf # the same three bindings as static config lines
scripts/
install_plugins.ps1 # Prefix + I
update_plugins.ps1 # Prefix + U
clean_plugins.ps1 # Prefix + M
psmux-sensible/
psmux-sensible.ps1
psmux-resurrect/
psmux-resurrect.ps1
plugin.conf
scripts/save.ps1, restore.ps1
psmux-theme-nord/
plugin.conf # pure option lines, sourced by psmux itself
psmux-theme-nord.ps1
What ppm.ps1 does when the config's run line executes it:
- Creates
~/.psmux/pluginsif it is missing. - Binds
Prefix + I,Prefix + U,Prefix + Mto its install, update and clean scripts (run-shell 'pwsh -NoProfile -File "...install_plugins.ps1"'). - Reads the declared plugins with
psmux show-options -g(every@pluginvalue) and, as a fallback, by scanning~/.psmux.conf,~/.psmuxrc,~/.tmux.conf, or~/.config/psmux/psmux.confforset -g @pluginlines. - For every declared plugin that is installed, runs its entry point:
<name>.ps1, then<name without psmux- prefix>.ps1, thenplugin.ps1, theninit.ps1. A plugin that ships aplugin.confis skipped here because psmux already sourced it at parse time, which keeps your own settings placed after the@pluginline from being overridden.
Prefix + I resolves each @plugin spec and clones it:
| Spec | What ppm clones |
|---|---|
psmux-plugins/psmux-sensible |
The psmux/psmux-plugins monorepo, extracting the psmux-sensible folder |
owner/repo |
https://github.com/owner/repo.git |
owner/repo/subdir |
owner/repo, extracting subdir |
https://... or git@... |
That URL directly |
psmux-sensible (bare name) |
Treated as psmux-plugins/psmux-sensible |
any of the above with #branch |
The same, with git clone --branch <branch> |
Git runs with the credential helper and terminal prompts disabled, so a misspelt spec fails with a clean message instead of an interactive sign-in window.
Note that PSMUX_DATA_DIR (which relocates psmux's registry and state files, see Configuration) does not move the plugin directory. Plugin paths are derived from USERPROFILE and XDG_CONFIG_HOME only.
psmux-sensible sets defaults with set -go (only if unset) so it never overrides your config: escape-time 50 (tmux-sensible uses 0; 50 ms is safer on Windows so escape sequences are not split), history-limit 50000, mouse on, mode-keys vi, focus-events on, display-time 2000, status-interval 5, base-index 1, pane-base-index 1, renumber-windows on, automatic-rename on. It binds Prefix + R to reload the config, Prefix + | and Prefix + - to split, and Shift+Left/Right to switch windows without a prefix.
psmux-resurrect binds Prefix + Ctrl-s (save) and Prefix + Ctrl-r (restore). A save records every session, window and pane layout with exact geometry, the working directory of every pane, the active pane and window, zoom state, pane titles, and the running command. Restore recreates them. Options mirror tmux-resurrect: @resurrect-dir (default ~/.psmux/resurrect), @resurrect-capture-pane-contents 'on', @resurrect-processes 'ssh python node' (or 'false' to restore no processes, ':all:' to restore all). Saves rotate (the latest 20 are kept) and a save whose environment is unchanged is skipped. @resurrect-overwrite 'on' kills and recreates a session that already exists instead of skipping it.
psmux-continuum runs resurrect saves automatically every @continuum-save-interval minutes (default 15, 0 disables), restores the last environment at server start when @continuum-restore 'on' is set, and can register psmux to start at boot with @continuum-boot 'on'. Same option names as tmux-continuum.
psmux-pain-control adds Prefix + h/j/k/l navigation, Prefix + Alt-h/j/k/l resizing, and |, \, -, _ splits. psmux-vim-navigator is the vim-tmux-navigator port: Ctrl-h/j/k/l without a prefix switch panes, or pass through when the pane's foreground program is vim, nvim, or fzf (psmux exposes the foreground process as #{pane_current_command}, which is what the plugin checks).
Status line plugins (psmux-cpu, psmux-battery, psmux-net-speed, psmux-git-status) gather data with Get-CimInstance Win32_Processor, Win32_Battery, Get-NetAdapterStatistics and git instead of /proc, sysfs or acpi, then publish it as user options. You place the values with format variables, for example:
set -g status-right '#{@cpu_display} #{@ram_display} | %H:%M'psmux-prefix-highlight reads the current status-right, prepends #{?client_prefix,...}, #{?pane_in_mode,...} and #{?synchronize-panes,...} segments, and writes it back. Same option names as tmux-prefix-highlight.
psmux-logging binds Prefix + Alt-o (toggle logging via pipe-pane), Prefix + Alt-p (screen capture), Prefix + Alt-i (full history capture), Prefix + Alt-c (clear history). psmux-sidebar toggles a directory tree pane with Prefix + Tab.
psmux-yank is retired (it lives in the repo's _trash folder). psmux writes to the Windows clipboard itself (src/clipboard.rs, called from copy mode): a mouse drag copies on release, and a copy mode yank copies the selection, with OSC 52 for the outer terminal when set-clipboard is not off. The plugin only wrapped Set-Clipboard, which the core no longer needs. Keep the @plugin line out of new configs.
The configuration lines are the same. The differences are all things that only exist on one operating system.
| tmux plugin | psmux plugin | What differs on Windows |
|---|---|---|
| tpm | ppm | bash tpm script became ppm.ps1; clones with git the same way; Prefix + I / U / M are identical (tpm uses alt-u for clean, ppm uses M) |
| tmux-sensible | psmux-sensible | escape-time defaults to 50 instead of 0; Prefix + R reloads ~/.psmux.conf |
| tmux-yank | (built in) | xclip/pbcopy/wl-copy became the native Windows clipboard; no plugin needed |
| tmux-resurrect | psmux-resurrect | save/restore scripts are .ps1; paths are Windows paths; same @resurrect-* options |
| tmux-continuum | psmux-continuum | interval timer runs from PowerShell; same @continuum-* options |
| tmux-pain-control | psmux-pain-control | identical bindings |
| vim-tmux-navigator | psmux-vim-navigator | process detection uses #{pane_current_command} (psmux reports the foreground console process) instead of ps |
| tmux-prefix-highlight | psmux-prefix-highlight | identical format variables |
| tmux-battery | psmux-battery | acpi / pmset became Get-CimInstance Win32_Battery |
| tmux-cpu | psmux-cpu | /proc/stat and top became Get-CimInstance Win32_Processor / Win32_OperatingSystem; values are published as #{@cpu_display} and #{@ram_display} |
| tmux-net-speed | psmux-net-speed | /proc/net/dev became Get-NetAdapter / Get-NetAdapterStatistics |
| tmux-logging | psmux-logging | pipe-pane sink is a PowerShell command instead of cat >> |
| tmux-sidebar | psmux-sidebar | uses tree when present, Get-ChildItem otherwise |
| catppuccin, dracula, nord, tokyonight, gruvbox, everforest, kanagawa, onedark, rosepine | psmux-theme-* | option lines are identical; the theme ships a plugin.conf that psmux sources natively |
For plugin authors, the Plugin Developer Guide has a bash to PowerShell translation table (show-option -gqv to show-options -g -v, xclip to Set-Clipboard, pgrep to Get-Process, cat /proc/stat to Get-CimInstance Win32_Processor, and so on).
Two paths exist, and both were verified on psmux 3.3.8.
1. Static translation of a .tmux entry script (no bash required). When a config line runs a file that ends in .tmux, psmux does not execute it. It reads the script, skips bash control flow, and applies every tmux set ..., tmux bind-key ..., tmux source-file ..., tmux run-shell ..., tmux if-shell ... and tmux set-hook ... line as a config line, expanding $CURRENT_DIR and $PLUGIN_DIR to the script's folder. Companion *_tmux.conf files in the same folder are sourced too. This is enough for themes and for plugins whose entry script only declares options and bindings.
# ~/.psmux.conf
run '~/.tmux/plugins/some-theme/some-theme.tmux'Verified: a .tmux file containing tmux set -g @loaded "yes" and tmux bind-key -n M-b display-message "bash plugin key" produced show -gv @loaded = yes and the binding in list-keys after psmux -f that.conf new-session -d. The translation only happens on the config path; psmux run-shell 'x.tmux' from the CLI or a key binding hands the path to PowerShell, which cannot run it, and nothing is applied.
2. Execute the bash plugin with Git Bash. If Git for Windows is installed, run the entry script through its bash. The script's tmux ... calls resolve to psmux because psmux installs a tmux.exe alias on PATH, and PSMUX_TARGET_SESSION steers them to the right server.
run "& 'C:/Program Files/Git/bin/bash.exe' '~/.tmux/plugins/some-plugin/some-plugin.tmux'"Verified with the same test script: @loaded became yes and M-b was bound by the bash process. Whether the rest of the plugin works depends on what its helper scripts call. sed, awk, grep, date and cut exist in Git Bash; xclip, pbcopy, acpi, /proc and sysfs do not. A plugin that only shells out to tmux and coreutils runs; a plugin that reads Linux system files needs the PowerShell port.
Yes, when the theme is option lines. Most themes are. Paste them into ~/.psmux.conf or keep them in a file and source-file it. Verified verbatim with the Nord palette:
set -g status-style "bg=#3b4252,fg=#d8dee9"
set -g status-left "#[bg=#81a1c1,fg=#2e3440,bold] #S "
set -g status-right "#[fg=#8fbcbb] %H:%M "
set -g pane-active-border-style "fg=#88c0d0"
set -g window-status-current-format "#[bg=#88c0d0,fg=#2e3440,bold] #I #W "psmux -f .\theme.conf new-session -d -s themed
psmux show-options -g -v status-style # bg=#3b4252,fg=#d8dee9
psmux show-options -g -v pane-active-border-style # fg=#88c0d0#[fg=...], #[bg=...], bold, #{?client_prefix,...}, #I, #W, #S, %H:%M and the other strftime fields all render. See Configuration for the full list of style options and Scripting for format variables.
A plugin is a folder with a PowerShell entry point named after the folder. This one adds a user option, a prefix-less key, and a status segment. It was tested from a path containing a space.
# ~/.psmux/plugins/hello/hello.ps1
$PSMUX = (Get-Command psmux).Source
# A user option other config can read with #{@hello_greeting}
& $PSMUX set -g '@hello_greeting' 'hi from my plugin' 2>&1 | Out-Null
# A binding (no prefix) that shows the option
& $PSMUX bind-key -n M-h display-message '#{@hello_greeting}' 2>&1 | Out-Null
# Prepend a status segment, once
$right = (& $PSMUX show-options -g -v status-right 2>&1 | Out-String).Trim()
if ($right -notmatch 'hello') {
& $PSMUX set -g status-right "#[fg=green][hello] $right" 2>&1 | Out-Null
}Load it from your config:
run '~/.psmux/plugins/hello/hello.ps1'or, to test against a running server without editing the config:
psmux run-shell 'C:/Users/you/.psmux/plugins/hello/hello.ps1'
psmux show -gv '@hello_greeting' # hi from my plugin
psmux show-options -g -v status-right # #[fg=green][hello] ...
psmux list-keys | Select-String 'M-h' # bind-key -T root M-h display-message #{@hello_greeting}
psmux display-message -p 'greeting=#{@hello_greeting}'Rules that keep a plugin robust:
- Quote option names that start with
@in PowerShell.@nameis PowerShell's splatting syntax; unquoted,psmux set -g @hello_greeting 'x'reaches psmux asset -g xand fails withset-option: empty value. Write'@hello_greeting'. This is the single most common plugin bug. - Use
set -gofor defaults you want the user to be able to override from their config. - Find the binary with
Get-Command psmux, pmux, tmuxrather than hard coding a path; the Windows installers put it in different places. - Bind helper scripts with
run-shell 'pwsh -NoProfile -File "<path>"'or giverun-shellthe bare.ps1path; both are safe for paths with spaces. Use forward slashes in paths you embed in config strings. - Reactions to events go through
set-hook -g. Verified: a config lineset-hook -g after-new-window "run-shell -b '<dir>/hook.ps1'"ran the script onnew-window, and the script sawPSMUX_TARGET_SESSIONset to the session name. - If a plugin needs a long running collector (cpu, net speed), start it once from the entry point and publish results with
set -g '@x_display'; the status line picks the value up on its nextstatus-intervalrefresh. - Ship a
plugin.confwith your staticset/bindlines when you can. psmux sources it synchronously at the@pluginline, so there is no startup flash and no dependency on ppm's load order.
The Plugin Developer Guide covers porting an existing tmux plugin step by step, and the psmux-plugins repo has a tests/ folder with the pattern used to test resurrect end to end.
Tmux Plugin Panel (tmuxpanel) is a Rust TUI that browses, installs, updates, removes, and previews plugins and themes for both psmux and tmux, working from a registry file rather than hand edited config. Install with cargo install tmuxpanel, choco install tmuxpanel, scoop install tmuxpanel, or winget install marlocarlo.tmuxpanel.
A plugin script does nothing and there is no error. The config is missing the run line that loads ppm (or the plugin's own run line). @plugin alone only sources a plugin.conf or a purely literal .ps1; a script that uses PowerShell variables needs the run line or a ppm load.
set-option: empty value for '...' when a plugin sets an @option. Unquoted @name in PowerShell (see the rules above). Quote it.
Execution policy. psmux runs .ps1 files with pwsh -NoProfile -ExecutionPolicy Bypass -File, so a restrictive policy does not block plugin scripts started by psmux. If you run a plugin script by hand from a PowerShell prompt and get an execution policy error, Set-ExecutionPolicy -Scope CurrentUser RemoteSigned fixes that prompt only.
Prefix + I does nothing. ppm was not loaded, so the binding does not exist. Check with psmux list-keys | Select-String ' I '. The run '~/.psmux/plugins/ppm/ppm.ps1' line must be at the bottom of the config and ppm.ps1 must exist at that path (~/.config/psmux/plugins/ppm/ is used automatically when the classic path does not exist).
Path with spaces. Give run-shell the .ps1 path directly, or wrap the whole command as run-shell 'pwsh -NoProfile -File "C:/Program Files/x/plugin.ps1"'. Both forms were verified. What fails is an unquoted path inside a -Command string.
A theme flashes the default status line first. The theme has no plugin.conf and relies on ppm to run its .ps1. Themes in the psmux-plugins repo ship a plugin.conf, which psmux applies synchronously; a third party theme can be converted by copying its set lines into a plugin.conf beside the script.
The plugin configured the wrong server. Only happens when a script ignores PSMUX_TARGET_SESSION and hard codes -L or -t. Let the environment variable route the calls.
run-shell output pops up over the pane. That is tmux behaviour for a foreground run-shell with output. Use run-shell -b for scripts that should stay silent, or send their output to Out-Null.
A key bound to a slow run-shell. The prefix indicator clears as soon as the key is pressed and the client keeps drawing and reading keys while the script runs; the output popup appears when it finishes. As in tmux, the rest of the binding waits for it: in bind C-s run-shell 'save.ps1' \; display-message saved the message shows only after save.ps1 exits. One difference: tmux holds keys typed during a foreground run-shell until it ends, while psmux delivers them to the pane straight away.
Do psmux plugins require bash, cygwin, or WSL?
No. Plugins from psmux-plugins are PowerShell. PowerShell 7 (pwsh) is preferred and Windows PowerShell 5.1 is used when pwsh is not installed.
Can I keep my ~/.tmux.conf plugin section?
The set -g @plugin lines are read from ~/.tmux.conf too. Change the tpm run line to ppm's run '~/.psmux/plugins/ppm/ppm.ps1' and swap tmux-plugins/tmux-sensible for psmux-plugins/psmux-sensible and so on. Theme lines stay as they are.
Where are user options stored?
In the server's option table for the lifetime of the server. psmux show-options -g lists them along with the built in options; psmux show -gv '@name' prints one value.
How often does #(command) in the status line run?
Every status-interval seconds (default 15). Set set -g status-interval 5 for status plugins that should refresh faster. psmux never runs the command more often than once per second.
Which hooks can a plugin use?
Run psmux help and look at the hooks section, or see the table above. client-attached and session-created fire at server start, so they are the right place for one time initialisation.
Does a plugin installed on Linux tmux work on psmux and vice versa? The declarations do. A bash plugin runs on psmux only through Git Bash and only if it avoids Linux specific tools; a PowerShell plugin runs on Linux tmux only if pwsh is installed there and the plugin avoids Windows specific cmdlets. Themes and option based plugins are portable in both directions.