Back to Blog

I wrote 300 lines of Python just to open tmux... like I really want

tmux-resurrect wasn't cutting it. So I built a script that reads YAML, creates 10 windows with emoji names, and auto-detects which Mac I'm on. Here's the whole thing.

Two Macs, zero patience

I have two Macs. A MacBook and a Mac Mini. Every time one of them rebooted — software update, power flicker, whatever — I'd stare at a blank terminal and think: right, now I need to rebuild my entire workspace.

Ten tmux windows. Four panes each. Specific directories. Specific commands running in specific panes. btop in the system monitoring window. nvim in the editor pane. nb shell in the notebook window. Claude in its own window with --model opus --dangerously-skip-permissions because of course.

tmux-resurrect existed. I used it. It saved sessions. But it never saved them right. Pane contents would come back garbled. Running processes wouldn't restart. Layouts would drift. And the worst part: it only knew about one machine. My MacBook's setup was different from my Mini's, and tmux-resurrect had no concept of "this is the MacBook config, load that one."

So I wrote a script.

tmux-setup.py

Here's the thing about scratching your own itch: you always overshoot. What started as "create some tmux windows" turned into 300 lines of Python with a Colors class, progress bars, a summary table, and emoji icons for every window.

The core of it was a TMUXSetup class built on libtmux:

class TMUXSetup:
    """Clean tmux session manager using YAML configuration"""
    
    def __init__(self, config_file: str = None):
        # Auto-detect configuration based on hostname
        if config_file is None:
            hostname = os.uname().nodename
            if "mini" in hostname.lower():
                config_file = "windows-mini.yaml"
            else:
                config_file = "windows.yaml"

        self.config_path = Path(__file__).parent / config_file
        self.config = self._load_config()
        self.server = None
        self.session = None
        self.windows_created = []

That hostname detection. That's the part that made me feel clever. os.uname().nodename — if it contains "mini", load windows-mini.yaml. Otherwise, load windows.yaml. Two Macs, two configs, zero manual switching.

The window creation was straightforward libtmux:

def create_window(self, window_config: Dict, index: int, total: int):
    name = window_config['name']
    icon = window_config.get('icon', '')
    path = os.path.expanduser(window_config.get('path', '~'))
    layout = window_config.get('layout', 'tiled')
    panes = window_config.get('panes', [{}])
    
    display_name = f"{icon} {name}"
    
    # Create new window
    window = self.session.new_window(
        window_name=display_name,
        start_directory=path
    )
    
    # Configure panes
    first_pane = window.panes[0]
    if panes[0] and panes[0].get('command'):
        first_pane.send_keys(panes[0]['command'], suppress_history=True)
    
    for pane_config in panes[1:]:
        pane = first_pane.split(start_directory=path)
        if pane and pane_config and pane_config.get('command'):
            pane.send_keys(pane_config['command'], suppress_history=True)
    
    window.select_layout(layout)

Split panes, send keys, set layout. That's it. That's the whole trick. Everything else — the progress bars, the colored output, the summary table at the end — was me procrastinating on the parts that actually mattered.

The YAML

The real soul of the thing was windows.yaml. This is what my daily workspace looked like, defined in 120 lines of config:

session:
  name: macbook-session

windows:
  - name: mac
    icon: "💻"
    path: /Users/txeo/
    layout: main-vertical
    panes:
      - command: cat > /dev/null
      - command: pwned txeo@somosunaola.org
      - command: pwned txeo@drolo.club
      - command: pwned txeo@txeo.club

  - name: claude
    icon: "💬"
    path: /Users/txeo
    layout: main-vertical
    panes:
      - command: claude --model opus --dangerously-skip-permissions
      - command: ls -la ~/.config/claude
      - command: nvim ~/.config/claude/config.yaml
      - command: ""

  - name: laporra
    icon: "🏟️"
    path: /Users/txeo/Git/htmx/laporra
    layout: main-vertical
    panes:
      - command: ""
      - command: git status
      - command: tail -f laporra.log
      - command: nvim .

  - name: system
    icon: "⚙️"
    path: /Users/txeo
    layout: main-horizontal
    panes:
      - command: btop
      - command: df-colors
      - command: nginx -t
      - command: nvim ~/espanso

  - name: nb
    icon: "🐅"
    path: /Users/txeo/.nb
    layout: main-vertical
    panes:
      - command: nb shell
      - command: ll
      - command: nb browse --daemon
      - command: list-notebooks

That's five of ten. There were also windows for tmux config editing, neovim config, two separate ngrok windows (apps and tunnels), and a web project called Somos Una Ola that had chafa rendering its favicon as ASCII art in one of the panes, because why not.

Every window had a name. Every window had an emoji. Every window had exactly the layout and commands I wanted. And it worked. I'd run the script, watch the progress bar fill up, see the summary table print out in color, and there was my workspace. Perfect. Every time.

The absurdity

Let me be honest about what this was: 300 lines of Python, plus a YAML file, plus libtmux and PyYAML as dependencies, plus colored terminal output with ANSI escape codes, plus a progress bar...

...to open a terminal.

class Colors:
    RESET = "\033[0m"
    BOLD = "\033[1m"
    GREEN = "\033[92m"
    BLUE = "\033[94m"
    YELLOW = "\033[93m"
    CYAN = "\033[96m"
    MAGENTA = "\033[95m"
    RED = "\033[91m"
    ORANGE = "\033[38;5;208m"
    PINK = "\033[38;5;213m"
    PURPLE = "\033[38;5;135m"

Eleven colors. For a script that runs once after a reboot.

def print_progress(self, current: int, total: int, item: str):
    percent = (current / total) * 100
    bar_length = 30
    filled_length = int(bar_length * current // total)
    bar = "█" * filled_length + "░" * (bar_length - filled_length)
    print(f"\r  {Colors.CYAN}[{bar}]{Colors.RESET} {percent:3.0f}% - {item}", end="")

A progress bar. For creating ten tmux windows. An operation that takes maybe two seconds total. But I wanted to see each window being created. I wanted the blocks filling in, one by one, 💻 mac, 💬 claude, 🏟️ laporra. It felt like the machine was waking up with me.

And then the summary:

def print_summary(self):
    print(f"  {'Window':<20} {'Path':<45} {'Layout':<15} {'Panes':<6}")
    print(f"  {'─' * 86}")
    for win in self.windows_created:
        name_with_icon = f"{win['icon']} {win['name']}"
        print(f"  {name_with_icon:<20} {win['path']:<45} "
              f"{win['layout']:<15} {win['panes']:<6}")

A table. With column headers. And a horizontal rule. Printed to a terminal that I'm about to switch away from immediately because the whole point is to start working in tmux.

I spent more time on the output formatting than on the actual window creation logic. That's the kind of developer I am. That's probably the kind of developer you are too, if you're reading this.

Why it actually matters

Here's what I didn't expect: the script changed how I thought about my workspace.

Before tmux-setup.py, my setup was whatever I remembered it was. I'd open windows, forget which one had btop, split panes in the wrong direction, lose track of what project was in which window. The workspace was a product of accumulated muscle memory and mild chaos.

After the script, the workspace was designed. I opened windows.yaml, looked at my ten windows, and thought about them. Do I still need the ngrok window? Should the Claude window come before the project windows? Is main-vertical really the right layout for system monitoring, or should that be main-horizontal?

The YAML became a blueprint. Not just for the machine — for how I wanted to spend my time. The order of the windows was the order of my priorities. The first window was always mac — the home base. The second was claude. The third was whatever project I was deep in. That ordering wasn't accidental. It was a decision I made once and never had to make again.

Every reboot went from "ugh, let me rebuild everything" to "let me run the script." Two seconds of progress bar, a summary table I'd never read, and I was working. Same windows. Same panes. Same commands. The continuity wasn't in the terminal state — tmux-resurrect could theoretically do that. The continuity was in the intention. I knew what my workspace was supposed to look like because I'd written it down.

The over-engineering was the point

I could have done this with a shell script. Thirty lines of bash. tmux new-window -n "💻 mac" -c ~/. tmux split-window. tmux send-keys "btop" Enter. Done. No Python. No YAML. No progress bar. No eleven colors.

But the bash version wouldn't have been fun. And if it wasn't fun, I wouldn't have maintained it. And if I didn't maintain it, I'd have gone back to rebuilding my workspace by hand after every reboot, and I'd have spent the rest of my life typing tmux new-window ten times every Monday morning.

The over-engineering was what kept me coming back. I'd see the progress bar and smile. I'd add a new window to the YAML and feel satisfied. I'd notice the hostname detection picking the right config on each machine and think, yeah, that's right. That's how it should be.

There's something to the idea that the best personal tools are the ones you enjoy using, not just the ones that work. This script worked. But more importantly, it felt good. And that's not nothing.

What's next

The script is still there. ~/.config/tmux/tmux-setup.py sitting on both my Macs, doing its job whenever I need it. It's the kind of code that only makes sense when you're the only user. Eleven color constants. A progress bar for a two-second operation. Hostname sniffing to pick the right YAML file.

Sometimes I look at it and think about what it could become. A proper tool, maybe. Something that doesn't care which terminal multiplexer you use. Something that other people could configure for their own workspaces. The core idea — define your workspace in a file, restore it on demand — feels universal enough.

But right now it's mine. It runs. It creates my ten windows with their emoji names and their four panes and their specific commands. It prints a progress bar that nobody watches and a summary table that nobody reads. And when it's done, my workspace is exactly where I left it.

That's all I wanted. Turns out 300 lines of Python was exactly the right amount of effort for that.

Back to Blog