Last active
June 9, 2026 16:20
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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