← back

pswitch — Quick tmux project switching

satisfaction ████░ 4/5

No AI for this one, I wanted to practice a bit and enjoy coding.

I upgraded my tmux project switcher. The old version worked well, but I had some friction with a ton of projects when I only really use a handful of them daily. Adding a most-recently-used ordering did the trick.

How it works

A python script (pswitch.py) keeps a tiny JSON state file with the projects root, scan depth, and an MRU-ordering list. A bash script wires it into tmux: it pipes the project list into fzf inside a display-popup, and on selection it does the tmux new-session + tmux switch-client dance, then records the pick so it floats to the top next time.

How to use

ln -s "$PWD/pswitch.py" /usr/local/bin/pswitch
pswitch config set --dir=~/Documents/projects

Then in ~/.tmux.conf:

bind o display-popup -E '<path>/pswitch/tmux.sh'

C-b o opens the picker.

Repo: github.com/ewoij/pswitch

Bonus — what Claude would have done

After writing the thing, I asked Claude the same question: green light to be you and have fun, how would you build this? Its answer was interesting enough I want to capture it. The rest of this section is Claude speaking.


If the goal is just the functionality, I'd reach for sesh — a Go-based tmux session manager that already does this and more (MRU via --src last, config templates, zoxide integration, startup hooks). brew install sesh, done.

If I had green light to enjoy building it, I'd lean on zoxide for the ordering bit instead of maintaining my own state file. zoxide already tracks frecency (frequency × recency), which beats pure MRU in practice — a project you touched once yesterday doesn't bury the project you live in.

The whole thing becomes ~15 lines of shell:

#!/usr/bin/env bash
root="${PSWITCH_DIR:-$HOME/Documents/projects}"
current=$(tmux display-message -p '#{session_name}')

sel=$(zoxide query -l \
  | grep "^$root/" \
  | awk -F/ -v cur="$current" '$NF != cur {print $NF}' \
  | fzf --no-sort --reverse --prompt='project ') || exit 0

path="$root/$sel"
tmux new-session -ds "$sel" -c "$path" 2>/dev/null
tmux switch-client -t "$sel"
zoxide add "$path"

What I like about it: no Python startup, no JSON state file, no top command to write. zoxide is the brain. Every cd you already do via z also feeds the ranking, so the popup and the shell stay in sync without effort.

What pswitch wins on, though: it's self-contained. One Python script, no external dependency. If zoxide ever changes its db format, mine breaks; pswitch doesn't. There's something to be said for tiny tools that will still work in 10 years.

What if you couldn't use zoxide?

Jonas threw a follow-up at me: same MRU behavior, but self-contained — no external state, no zoxide. What tech?

Different answer entirely. In that world I'd write it in bash + a plain text file. The MRU bookkeeping is trivial — prepend the selection to a list, dedup keeping first occurrence — and bash with awk does it in one pass. Bash also starts instantly, which actually matters for a popup that needs to feel snappy. Python's 50–80 ms cold-start is half the perceived latency.

The whole thing fits in ~25 lines:

#!/usr/bin/env bash
set -euo pipefail

root="${PSWITCH_DIR:-$HOME/Documents/projects}"
state="${XDG_STATE_HOME:-$HOME/.local/state}/pswitch/mru"
mkdir -p "$(dirname "$state")" && touch "$state"

case "${1:-pick}" in
  list)
    { cat "$state"
      find "$root" -mindepth 1 -maxdepth 1 -type d | sort
    } | awk '!seen[$0]++' | while read -r p; do
      [[ -d "$p" ]] && echo "$p"
    done
    ;;
  top)
    { echo "$2"; cat "$state"; } | awk '!seen[$0]++' > "$state.tmp"
    mv "$state.tmp" "$state"
    ;;
  pick)
    sel=$("$0" list | fzf --no-sort --reverse) || exit 0
    name=$(basename "$sel")
    tmux new-session -ds "$name" -c "$sel" 2>/dev/null
    tmux switch-client -t "$name"
    "$0" top "$sel"
    ;;
esac

Two tricks worth pointing at:

  • awk '!seen[$0]++ — the idiomatic "dedup, keep first occurrence" line. Pipe MRU first, then alphabetical, and awk gives you "MRU items in order, then new dirs in alphabetical, no duplicates" in one pass.
  • [[ -d "$p" ]] && echo "$p" — auto-prunes deleted projects from the state file. No clean command needed; staleness is self-healing.

Bash gets dismissed too easily. For "manipulate text, glue tools together, sit between fzf and tmux" it's genuinely the right shape. The places where it falls down — data structures, error handling, anything cross-platform — just don't apply here.

If you want to learn it deeper, three things punch above their weight and turn bash from "scary" to "fluent":

  1. set -euo pipefail at the top of every script. Exits on error, fails on unset variables, doesn't swallow errors inside pipes. Once you have this, bash stops silently doing the wrong thing.
  2. awk for text manipulation. Most ugly bash scripts get ugly because people avoid awk and try to do everything in pure bash loops.
  3. shellcheck. A linter that knows bash better than most humans. Catches ~90% of the gotchas (quoting, word splitting, [[ vs [) before they bite.