Last active
June 10, 2026 17:32
-
-
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: | |
| Run `tmux-tabs` in any tmux pane — it splits off a 25%-wide left sidebar and | |
| hands the original pane back as the content pane. | |
| Quit: click the (X) button, or 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, check=False): | |
| """Run tmux, returning stripped stdout. check=True raises CalledProcessError on failure.""" | |
| return subprocess.run(["tmux", *args], capture_output=True, text=True, check=check).stdout.strip() | |
| def pane_var(pane, fmt): | |
| return tmux("display-message", "-p", "-t", pane, fmt) | |
| def tmux_windows(): | |
| # Two free-text fields (name, title) can hold any delimiter, so put each field on its own line: | |
| # tmux collapses newlines in them, making newline the one safe separator. Parse in groups of 7. | |
| f = tmux("list-windows", "-F", "#{window_id}\n#{window_index}\n#{window_name}\n" | |
| "#{window_bell_flag}\n#{window_flags}\n#{window_panes}\n#{pane_title}").splitlines() | |
| return [ | |
| {"id": wid, "index": idx, "name": name, "bell": bell == "1", | |
| "active": "*" in flags, "last": "-" in flags, "panes": int(panes), "title": title} | |
| for wid, idx, name, bell, flags, panes, title in zip(*[iter(f)] * 7) | |
| ] | |
| 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([], recompose=True) | |
| confirming = reactive(False, recompose=True) | |
| _pane_id = None | |
| _current_window = None # window index the sidebar currently sits in | |
| _selected_pane = None # content pane beside us; bounce focus here, None when none exists | |
| _placeholders = None # window_id -> placeholder pane_id (thin "cat" pane holding the slot) | |
| _width = None # last placeholder/sidebar width, reused for new placeholders | |
| _fifo_path = None | |
| _fifo_fd = None | |
| @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; background: yellow; color: #4b0082; text-style: bold; }} | |
| .btn {{ padding: 0 1; background: yellow; color: #4b0082; }} | |
| .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 gus pozuelo[/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._placeholders = {} | |
| self._kill_strays() # reap placeholders a crashed instance left, keeping their width | |
| self._setup_fifo() | |
| self.set_interval(1.0, self.refresh_windows) | |
| self.refresh_windows() # provision placeholders for all other windows now | |
| def _kill_strays(self): | |
| # Kill (tt-placeholder) panes left by a crashed instance, remembering the last width seen so | |
| # new placeholders reuse it. The title is read only here; the dict is truth elsewhere. | |
| for line in tmux("list-panes", "-a", "-F", "#{pane_id}|#{pane_width}|#{pane_title}").splitlines(): | |
| pane, width, title = line.split("|", 2) | |
| if title == "(tt-placeholder)": | |
| self._width = int(width) | |
| tmux("kill-pane", "-t", pane) | |
| def on_unmount(self): | |
| # Placeholders are our own artifacts, so we own their lifecycle (unlike the shared hooks/FIFO, | |
| # which we deliberately leave). Reap them on a graceful quit (q or [Y]). | |
| for ph in self._placeholders.values(): | |
| tmux("kill-pane", "-t", ph) | |
| def _setup_fifo(self): | |
| # No teardown on quit: the hooks are global and shared, so a dying instance must not remove | |
| # them (it would break a relaunched one). We deliberately leave the FIFO in /tmp — re-created | |
| # fresh on every launch below, and the OS clears /tmp in due time. See: never add on_unmount. | |
| # 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. | |
| # after-new-window covers new windows, which don't fire after-select-window. | |
| # 1<> (read-write open) instead of >> so a write never blocks when no reader is attached — | |
| # the hook is global and outlives us, so a stale FIFO must not hang and pile up sh processes. | |
| hook = f"run-shell -b 'echo #{{window_index}} 1<>{self._fifo_path}'" | |
| tmux("set-hook", "-g", "after-select-window", hook) | |
| tmux("set-hook", "-g", "after-new-window", 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) | |
| # Refresh first so switch_to can resolve a just-created window (and provision its placeholder). | |
| self.refresh_windows() | |
| active = tmux("display-message", "-p", "#{window_index}") | |
| if active != self._current_window: | |
| self.switch_to(active) | |
| def on_app_focus(self): | |
| # The sidebar never holds focus (buttons react on mouse-down regardless). Bounce focus | |
| # back to the content pane we sit beside. If it's gone (closed) or unknown, follow to | |
| # another window instead. | |
| try: | |
| tmux("select-pane", "-t", self._selected_pane, check=True) | |
| except Exception: | |
| if target := self._other_window(): | |
| self.switch_to(target) | |
| def _other_window(self): | |
| """Index of a window to flee to when our content pane is gone: last-active, else any other.""" | |
| self.windows = tmux_windows() | |
| return (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)) | |
| def refresh_windows(self): | |
| self.windows = tmux_windows() | |
| self._sync_placeholders() | |
| def _sync_placeholders(self): | |
| # Every window except the one holding the sidebar gets a thin placeholder in the sidebar's | |
| # slot, so content panes keep a constant width whether the sidebar or a placeholder sits | |
| # beside them. Provision missing ones, resize all to the current width, and reap orphans. | |
| width = str(self._sidebar_width()) | |
| current = self._current_id() | |
| live = {w["id"] for w in self.windows} | |
| for wid in list(self._placeholders): # drop entries for windows that no longer exist | |
| if wid not in live: | |
| del self._placeholders[wid] | |
| for w in self.windows: | |
| if w["id"] == current: | |
| continue | |
| if w["panes"] == 1 and w["id"] in self._placeholders: | |
| # Reap: our placeholder is the SOLE pane (all content gone) — let the window close. | |
| # panes==1 is only valid for reaping; creation keys on dict membership, since a | |
| # content window may legitimately hold several panes. | |
| tmux("kill-pane", "-t", self._placeholders.pop(w["id"])) | |
| continue | |
| ph = self._placeholders.get(w["id"]) or self._make_placeholder(w["index"], width) | |
| self._placeholders[w["id"]] = ph | |
| tmux("resize-pane", "-t", ph, "-x", width) | |
| def _sidebar_width(self): | |
| # The sidebar's current width when it's a sane thin pane (<= half the window) becomes the | |
| # remembered width; when it's momentarily full-window, reuse the last good value instead. | |
| win = int(pane_var(self._pane_id, "#{window_width}")) | |
| pane = int(pane_var(self._pane_id, "#{pane_width}")) | |
| if pane <= win // 2: | |
| self._width = pane | |
| return max(10, min(self._width or win // 4, win // 2)) | |
| def _make_placeholder(self, win_index, width): | |
| # A thin left pane running bare `cat` (minimal no-op process). The (tt-placeholder) title is | |
| # only a recovery marker for kill-on-init; the dict is the source of truth elsewhere. | |
| content = tmux("list-panes", "-t", win_index, "-f", "#{pane_active}", "-F", "#{pane_id}") | |
| ph = tmux("split-window", "-t", content, "-hb", "-l", width, "-d", "-P", "-F", "#{pane_id}", "cat") | |
| tmux("select-pane", "-t", ph, "-T", "(tt-placeholder)") | |
| return ph | |
| def _current_id(self): | |
| return next((w["id"] for w in self.windows if w["index"] == self._current_window), None) | |
| 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}") | |
| # The content pane to bounce focus to; None if the only pane there is us. | |
| self._selected_pane = target_pane if target_pane != self._pane_id else None | |
| if win_index == self._current_window: | |
| if self._selected_pane: # already here; just drop focus onto the content pane | |
| tmux("select-pane", "-t", self._selected_pane) | |
| return | |
| target_id = next(w["id"] for w in self.windows if w["index"] == win_index) | |
| old_id = self._current_id() | |
| # Swap the sidebar with the target window's placeholder: both windows keep their slot and | |
| # width, so no content pane reflows. The displaced placeholder fills the window we leave. | |
| # -d: don't make the placeholder active in the window we leave, else its (tt-placeholder) | |
| # title would show as that window's title in the list (list-windows reports the active pane). | |
| placeholder = self._placeholders.get(target_id) or self._make_placeholder(win_index, self._sidebar_width()) | |
| tmux("swap-pane", "-d", "-s", self._pane_id, "-t", placeholder) | |
| self._placeholders[old_id] = placeholder | |
| del self._placeholders[target_id] | |
| # The placeholder now sits where the sidebar was active in the window we left; activate that | |
| # window's content pane so its title (not "(tt-placeholder)") shows in the list. | |
| old_panes = tmux("list-panes", "-t", self._current_window, "-F", "#{pane_id}").splitlines() | |
| content = next((p for p in old_panes if p != placeholder), None) | |
| if content: | |
| tmux("select-pane", "-t", content) | |
| tmux("select-window", "-t", win_index) | |
| if self._selected_pane: | |
| tmux("select-pane", "-t", self._selected_pane) | |
| # Repaint the highlight in-memory (no tmux re-query): old window loses active, new gains it. | |
| self.windows = [{**w, "active": w["index"] == win_index, "last": w["index"] == self._current_window} | |
| for w in self.windows] | |
| 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