Last active
May 19, 2026 10:41
-
-
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 os | |
| import subprocess | |
| from textual.app import App, ComposeResult | |
| from textual.containers import Vertical | |
| from textual.reactive import reactive | |
| from textual.widgets import Label | |
| def tmux(*args): | |
| return subprocess.run(["tmux", *args], capture_output=True, text=True).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_click(self): | |
| self._app_ref.switch_to(self.win_index) | |
| class TmuxTabs(App): | |
| DEFAULT_CSS = "" | |
| BINDINGS = [("q", "quit", "Quit")] | |
| windows: reactive[list] = reactive([], recompose=True) | |
| _pane_id: str = "" | |
| _current_window: str = "" | |
| @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; }} | |
| #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: | |
| yield Vertical(*[WindowItem(w, self) for w in self.windows], id="windows") | |
| 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.set_interval(1.0, self.poll) | |
| def poll(self): | |
| self.windows = tmux_windows() | |
| # If our window has only us, the content pane was closed — move to another window | |
| 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) | |
| win_width = int(pane_var(self._pane_id, "#{window_width}")) | |
| if int(pane_var(self._pane_id, "#{pane_width}")) >= win_width * 0.9: | |
| tmux("resize-pane", "-t", self._pane_id, "-x", str(win_width // 4)) | |
| return | |
| active = next((w["index"] for w in self.windows if w["active"]), None) | |
| if active and active != self._current_window: | |
| self.switch_to(active) | |
| 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}") | |
| width = pane_var(self._pane_id, "#{pane_width}") | |
| tmux("move-pane", "-h", "-s", self._pane_id, "-t", target_pane, "-d", "-b") | |
| tmux("select-window", "-t", win_index) | |
| tmux("resize-pane", "-t", self._pane_id, "-x", width) | |
| tmux("select-pane", "-t", target_pane) | |
| self._current_window = win_index | |
| if __name__ == "__main__": | |
| os.environ.setdefault("TEXTUAL_THEME", "") | |
| TmuxTabs.CSS = TmuxTabs.build_css() | |
| TmuxTabs().run(inline=False) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment