Skip to content

Instantly share code, notes, and snippets.

@gatopeich
Last active June 9, 2026 16:20
Show Gist options
  • Select an option

  • Save gatopeich/844c634051d087d09b217e09d2158da2 to your computer and use it in GitHub Desktop.

Select an option

Save gatopeich/844c634051d087d09b217e09d2158da2 to your computer and use it in GitHub Desktop.
Like Claude Squad, without fuss: A vertical TMUX sidebar to track all your Agent sessions at a glance
#!/usr/bin/env python3.12
"""
tmux-tabs — vertical window sidebar for tmux.
Shows all tmux windows in a narrow left pane with bell highlights and terminal
titles. Click any window to switch to it. Automatically follows focus when a
new window is opened (e.g. via F2 in byobu).
Usage:
Launch in a 15%-wide left split:
tmux split-window -hb -p 15 tmux-tabs
Recommended: bind to a key in ~/.tmux.conf:
bind -n F9 split-window -hb -p 15 tmux-tabs
Quit: press q inside the sidebar.
Requirements:
pip install --user textual (python3.12)
Colors are read live from the tmux theme (window-status-style, etc.) so the
sidebar matches your current byobu/tmux color scheme automatically.
"""
import asyncio
import os
import subprocess
import sys
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Label
def tmux(*args):
return subprocess.run(["tmux", *args], capture_output=True, text=True, check=False).stdout.strip()
def pane_var(pane, fmt):
return tmux("display-message", "-p", "-t", pane, fmt)
def tmux_windows():
out = tmux("list-windows", "-F",
"#{window_index}|#{window_name}|#{window_bell_flag}|#{window_flags}|#{pane_title}")
return [
{"index": idx, "name": name, "bell": bell == "1",
"active": "*" in flags, "last": "-" in flags, "title": title}
for line in out.splitlines()
for idx, name, bell, flags, title in [line.split("|", 4)]
]
def parse_style(style):
return {k: v or True for k, _, v in (p.strip().partition("=") for p in style.split(","))}
def tmux_styles():
keys = {"normal": "window-status-style", "current": "window-status-current-style",
"bell": "window-status-bell-style", "status": "status-style"}
return {name: parse_style(tmux("show-options", "-gv", key)) for name, key in keys.items()}
def style_to_css(s, base=None):
bg = s.get("bg", base.get("bg", "default") if base else "default")
fg = s.get("fg", base.get("fg", "default") if base else "default")
if s.get("reverse"):
bg, fg = fg, bg
bold = "\n text-style: bold;" if s.get("bold") else ""
return f"background: {bg};\n color: {fg};{bold}"
class WindowItem(Label):
def __init__(self, win, app_ref):
self.win_index = win["index"]
self._app_ref = app_ref
icon = "🔔" if win["bell"] else ("▶" if win["active"] else " ")
title = win["title"].lstrip("✳").strip()
label = f"{icon} [b]{win['name']}[/b]\n [dim]{title}[/dim]" if title else f"{icon} [b]{win['name']}[/b]"
classes = "window" + (" bell" if win["bell"] else "") + (" active" if win["active"] else "")
super().__init__(label, classes=classes, markup=True)
def on_mouse_down(self):
# MouseDown (not Click): the forwarded press arrives on the first click even when our
# pane is unfocused, whereas Click needs a MouseUp pair that the focus-grab click drops.
self._app_ref.switch_to(self.win_index)
class Button(Label):
"""A clickable banner button that calls a named app method on mouse-down."""
def __init__(self, label, app_ref, action):
self._app_ref = app_ref
self._action = action
# markup=False: "[X]"/"[Y]"/"[N]" must render literally, not be parsed as Rich tags.
super().__init__(label, classes="btn", markup=False)
def on_mouse_down(self):
getattr(self._app_ref, self._action)()
class TmuxTabs(App):
DEFAULT_CSS = ""
BINDINGS = [("q", "quit", "Quit")]
windows: reactive[list] = reactive([], recompose=True)
confirming: reactive[bool] = reactive(False, recompose=True)
_pane_id: str = ""
_current_window: str = ""
_fifo_path: str = ""
_fifo_fd: int = 0
@classmethod
def build_css(cls):
s = tmux_styles()
base = s["status"]
bg = s["normal"].get("bg", base.get("bg", "#94cdba"))
fg = s["normal"].get("fg", base.get("fg", "black"))
normal, bell, current = (style_to_css(s[k], base) for k in ("normal", "bell", "current"))
return f"""
* {{ scrollbar-background: {bg}; scrollbar-color: {fg}; scrollbar-size: 0 0; }}
Screen {{ background: {bg}; color: {fg}; layers: base; }}
#banner {{ height: auto; width: 100%; background: {bg}; border-bottom: block yellow; }}
#title {{ padding: 0 1; width: 1fr; color: yellow; }}
.btn {{ padding: 0 1; color: yellow; }}
.btn:hover {{ text-style: reverse; }}
#windows {{ height: 1fr; overflow-y: auto; background: {bg}; align: left middle; }}
.window {{ padding: 0 1; height: 2; width: 100%; {normal} }}
.window .dim {{ color: {fg}; text-style: dim; }}
.window:hover {{ background: {fg}; color: {bg}; }}
.bell {{ {bell} }}
.bell:hover {{ {bell} text-style: bold reverse; }}
.active {{ {current} }}
.active:hover {{ {current} }}
"""
def compose(self) -> ComposeResult:
if self.confirming:
banner = [Label("close?", id="title"), Button("(Y)", self, "exit"),
Button("(N)", self, "cancel_close")]
else:
banner = [Label("[b]tmux-tabs by gatopeich[/b]", id="title", markup=True),
Button("(X)", self, "ask_close")]
yield Horizontal(*banner, id="banner")
yield Vertical(*[WindowItem(w, self) for w in self.windows], id="windows")
def ask_close(self):
self.confirming = True
def cancel_close(self):
self.confirming = False
def on_mount(self):
self._pane_id = os.environ["TMUX_PANE"]
self._current_window = pane_var(self._pane_id, "#{window_index}")
tmux("select-pane", "-t", self._pane_id, "-T", "tmux-tabs")
self.windows = tmux_windows()
self._setup_fifo()
self.set_interval(1.0, self.refresh_windows)
def on_unmount(self):
# Graceful quit (q or [Y]) routes through here — remove our global hooks and the FIFO
# so they don't linger writing to a dead pipe. An external kill won't reach this.
tmux("set-hook", "-gu", "after-select-window")
tmux("set-hook", "-gu", "window-linked")
if os.path.exists(self._fifo_path):
os.unlink(self._fifo_path)
def _setup_fifo(self):
# session_id is like "$1"; strip the $ so it can't be shell-expanded inside the hook
session_id = pane_var(self._pane_id, "#{session_id}").lstrip("$")
self._fifo_path = f"/tmp/tmux-tabs-{session_id}"
if os.path.exists(self._fifo_path):
os.unlink(self._fifo_path)
os.mkfifo(self._fifo_path)
# On window switch or creation, tmux writes the relevant window index to the FIFO.
# window-linked covers new windows, which don't fire after-select-window.
hook = f"run-shell -b 'echo #{{window_index}} >> {self._fifo_path}'"
tmux("set-hook", "-g", "after-select-window", hook)
tmux("set-hook", "-g", "window-linked", hook)
self._fifo_fd = os.open(self._fifo_path, os.O_RDONLY | os.O_NONBLOCK)
asyncio.get_event_loop().add_reader(self._fifo_fd, self._on_window_switched)
def _on_window_switched(self):
# A hook fired (select or new window). Follow the actually-active window, not the
# hook's index: a backgrounded new-window reports its index without becoming active.
os.read(self._fifo_fd, 4096)
active = tmux("display-message", "-p", "#{window_index}")
if active != self._current_window:
self.switch_to(active)
self.refresh_windows()
def on_app_focus(self):
# Gained focus means the user's pane was closed; move sidebar to another window
self.windows = tmux_windows()
if pane_var(self._pane_id, "#{window_panes}") == "1":
target = (next((w["index"] for w in self.windows if w.get("last")), None)
or next((w["index"] for w in self.windows if not w["active"]), None))
if target:
self.switch_to(target)
def refresh_windows(self):
self.windows = tmux_windows()
def switch_to(self, win_index):
# Resolve target window's active pane (window-number target is ambiguous from client context)
target_pane = tmux("list-panes", "-t", win_index, "-f", "#{pane_active}", "-F", "#{pane_id}")
if win_index == self._current_window:
# Already here; moving our pane into its own window would error. Just drop focus
# onto the content pane so a click always gets the user out of the sidebar.
if target_pane != self._pane_id:
tmux("select-pane", "-t", target_pane)
return
# Cap to a quarter of the target window: our own width is meaningless when it's a full pane.
width = min(int(pane_var(self._pane_id, "#{pane_width}")), int(pane_var(target_pane, "#{window_width}")) // 4)
# Join at this width in one move so the content pane never re-lays-out (no flicker).
tmux("move-pane", "-h", "-l", str(width), "-s", self._pane_id, "-t", target_pane, "-d", "-b")
tmux("select-window", "-t", win_index)
tmux("select-pane", "-t", target_pane)
self._current_window = win_index
if __name__ == "__main__":
if "--sidebar" in sys.argv:
os.environ.setdefault("TEXTUAL_THEME", "")
TmuxTabs.CSS = TmuxTabs.build_css()
TmuxTabs().run(inline=False)
else:
tmux("split-window", "-hb", "-l", "25%", f"{__file__} --sidebar")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment