{"article":{"slug":"ten-years-of-tmux-and-the-1-495-lines-of-zsh-it-cost-me","title":"Ten years of tmux, and the 1,495 lines of zsh it cost me","subtitle":null,"summary":"Yogesh Lonkar measured a tmux status bar burning about 15% of a CPU core, then rewrote a decade of forking shell scripts into a leaner setup—and documents what 1,495 lines of zsh had been doing the whole time.","content_type":"blog_post","language":"en","canonical_url":"https://yogesh.lonkar.org/posts/ten-years-of-tmux/","author":{"name":"Yogesh Lonkar","url":"https://yogesh.lonkar.org/","person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Yogesh Lonkar","url":"https://yogesh.lonkar.org/","listing_slug":null,"listing":null},"topics":[{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"},{"name":"Performance","slug":"performance","url":"https://listedarticles.com/topics/performance"},{"name":"Engineering","slug":"engineering","url":"https://listedarticles.com/topics/engineering"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":2974,"reading_minutes":13,"published_at":"2026-09-24T00:00:00.000Z","added_at":"2026-09-24T12:27:34.756Z","updated_at":"2026-09-24T12:27:34.756Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/ten-years-of-tmux-and-the-1-495-lines-of-zsh-it-cost-me","markdown_url":"https://listedarticles.com/articles/ten-years-of-tmux-and-the-1-495-lines-of-zsh-it-cost-me.md","example":false,"citation":"Yogesh Lonkar, Yogesh Lonkar. \"Ten years of tmux, and the 1,495 lines of zsh it cost me.\" 24 Sept 2026. https://yogesh.lonkar.org/posts/ten-years-of-tmux/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://yogesh.lonkar.org/posts/ten-years-of-tmux/"},"body_markdown":"TL;DR I’ve used tmux since 2016 because I can script it. Over ten years my config grew into 1,495 lines of zsh across 13 scripts, and in May 2026 the status bar alone was burning 15.4% of a core on battery. The fix was one `#()` call instead of six, and the scripts moved into one Rust daemon, tmux-companion. The bar costs 1.8% of a core on a spike and 0.3% idle now.\n\n\nI’ve been using tmux since somewhere in 2016 and I’ve never seriously tried to leave.\n\nPeople usually sell it on the detach trick, and that part is real. An ssh session drops, the work carries on, I reattach and everything’s where I left it. Sessions on my laptop live for months across sleeps and reboots.\n\n## It’s flexible Link to heading\n\nI stayed for a different reason, tmux is scriptable all the way down. Every binding can run a command, every command can read the state of the session, and all of that is out of the box without a single plugin. So I build my own developer experience on top of it, and I’ve customised it heavily.\n\nOnce a binding is in my fingers my attention stays on the problem in front of me instead of on the procedure for reaching it, so the difference between “I need to run this against staging” and running it is pulling one chord instead of four windows and a paste.\n\ntmux is also cheap. With a dozen sessions open it uses less memory than most IDEs use for one project and sits at nothing when idle, so I can leave sessions running for months without once thinking about what they’re costing me.\n\n## The trail it left in my dotfiles Link to heading\n\nI can date most of this, because my private dotfiles repo has been running the whole time and git kept the receipts.\n\nFor the first year I didn’t care about dotfiles, vim’s included, because they were small and I didn’t see the point of maintaining a repo for them. I don’t remember which YouTube video it was, but one was about tmux and neovim, and the person showed his dotfiles repo and explained how it grew, and that clicked. If you use a tool for years it grows on you and you want to make it yours, and a dotfiles repo is an easy way to keep track of that, which also shows you your own mistakes and likings.\n\nFirst commit in it was the README, second commit was `.tmux.conf`, 51 lines.\nThat first file already had the things I still set: `escape-time 0`, windows and panes numbered from 1, `renumber-windows on`, and a comment about fixing the split path “for tmux 1.9”.\n\nIt also bound `prefix a` to send the prefix through, which means I was already sshing into something that was itself running tmux.\nI was working on test databases on a VPS box that needed a lot of editing to make them work, the jobs ran for more than 30 minutes, and my connection sometimes dropped, which failed the job 3-4 times, and each time I had to restore the backup and start again.\nThere was also the headache of scp-ing the scripts across, as the box didn’t have access to VCS.\n\nPowerline arrived the next day, 1 August 2017, and stayed for years. I configured and customised it, then hit its limits: powerline is a DSL over what tmux already gives you raw, and going raw is what let me build what I wanted. The limit I hit was working with JSON and making segments dynamic and runtime-aware, which at that time I couldn’t figure out. So I started playing with my own bash scripts for segments, and that’s when the customising got serious.\n\nA year later, on 17 August 2018, I wired up tmux-resurrect on `C-s` and `C-r`, told it to bring back `vim mvim \"git log\"`, turned on `@resurrect-capture-pane-contents`, and set `default-command` to `reattach-to-user-namespace` so that copying out of a pane put something in the macOS clipboard.\nThe same commit added seven lines of mouse wheel bindings, which is what scrolling in tmux cost you in 2018.\nI rarely use resurrect nowadays, mostly because of session autosave and my laptop not dying on me randomly.\n\nOn 1 March 2019 I opened pull request `#2` against my own dotfiles, “Feature/status-bar-improvements”.\nI knew I’d be committing and reverting things dozens of times, and I wanted it to land as one change I could revert if I didn’t like it in a week.\nI pushed 9 commits, most of them squashed on my machine first.\n\nI moved from bash to zsh on 31 December 2019.\n\nThen five years where the tmux config barely moves.\n2021 fixes `TERM` and reconfigures powerline, 2022 and 2023 and 2024 are one “changes” commit each, and the config sits there working while I get on with the job.\nThe pile grew somewhere else during those years, one zsh file per thing the bar needed to know.\n\n## The pile Link to heading\n\nMine lived in `~/.config/tmux/comrades` and came to 1,495 lines of zsh across 13 scripts, plus another 294 lines of generators and probes.\nI know `.config` is meant for configs and not scripts, which I fixed later.\nThat directory held:\n\n- A project switcher.\n- A key-binding search.\n- A history runner.\n- Three scripts for theme picker, preview and generator.\n- Something to open whatever’s under the cursor.\n- Something to close a session without leaving nvim swap files everywhere.\n- zoxide window picker\n- a custom window toggler based on process running in window\n\nI wrote each one on the day something annoyed me.\nThe history command runner was the most frustrating: while working on code I needed to run a script, which I could loop but had to wait until I’d saved the changes, so at first I tried `for` and `read` so it ran manually, but then I had to keep switching windows, and if I used a split pane then vim wrapped long lines or I had to scroll, and it wasn’t a good time.\nSo I wrote the history runner and added restart on a key press, so I can run it anytime or view its output to copy from.\n\nThe script that opens vim on a file path found in copy-mode-vi was obvious once I started doing a lot of testing with vim as my editor and my onchange script as an auto runner for anything.\n\nNot all of it was zsh.\nI also had a Go CLI that did various tasks for me on the command line, and one of them was drawing git status in tmux, which is where `gst` comes from.\nI don’t remember where I got the inspiration to write it.\nThe first Go version was sloppy and had SQLite embedded in it, which I later replaced with an in-memory cache.\n\nI never added any of it up till May 2026.\n\n## Then I measured it Link to heading\n\nIn May 2026 I noticed my laptop was always running hot, with the CPU high for no reason I could name.\nChrome and Docker were there, I was locked in on Reddit for an hour or so and the cooling fans slowly gained speed.\nSo I quit Chrome and killed Docker Desktop, but CPU utilisation was still at 20%, with nothing running in my sessions.\nI went to Activity Monitor first, then `top`, then `ps` to understand the process hierarchy, and the open files in Activity Monitor made it clear it was my CLI and the other scripts, all started by tmux.\nThe rest of the 20% was menubar apps and macOS processes, which I googled and decided not to touch.\n\ntmux refreshes the status bar on `status-interval`, once a second here.\nMy bar called six programs to draw one line of text.\nOn battery that was 15.4% of one core, all day, so tmux wasn’t sitting at nothing any more.\n\nComputing every segment on my bar takes 2.6 ms, but a fork and exec costs 12.4 ms of CPU on my machine, 14.6 as tmux runs it through `sh -c`, so almost all of it went on starting programs.\nSix of those a second came to 153.77 ms of CPU for every second of wall clock, 80.90 in the tmux server and 72.88 in the spawns.\nThe worst case was worse and had a different cause: the old `net` segment hit a `netstat` hang about one call in three, which took a single refresh past 1,100 ms.\n\nThe middle of the bar was the worst of it, since that segment ran once per window instead of once per bar, and tmux’s own `window-status-format` had been sitting there doing the same job the whole time.\nOn the left, a client count and a glyph that meant “nvim is suspended somewhere”, and the second one walked every process on the machine to decide whether to draw one character.\n\nThe pickers paid the same cost.\nA popup that starts zsh, sources a config and pipes into `fzf` spends most of its latency before the first frame shows up, and none of that’s the search.\n\n## One process Link to heading\n\nI ported the Go CLI to Rust.\nI chose Rust because I found it fun, nothing in particular except that I liked its memory management and wanted to write something in it.\nOn 5 June 2026 I pointed the bar at the port, tmux-companion, and deleted 324 lines of zsh in one commit, `battery-life.zsh`, `net-monitor.zsh`, `check-clients.zsh`, `tmux-session-name-format.zsh`, and `window-status.zsh` at 154 lines on its own.\nThe bar was still slow, because I’d swapped what ran inside six `#()` calls without touching the six.\n\nOn 11 August I collapsed the six calls into one.\n\n```\n# one call, not six\nset -g status-right \"#(tmux-companion status-right #{pane_current_path})\"\n```\nThe daemon holds its caches in memory and answers over a unix socket. Every binding that used to start a shell sends it one line of JSON and reads one back. The pickers draw in the client, since the daemon hasn’t got a terminal.\n\n|  | before | after | \n|---|---|---|\n| status bar | 153.77 ms/s, 15.4% of a core | 18.43 ms/s, 1.8% on a spike | \n| status bar, idle |  | 0.3% | \n| worst single refresh | over 1,100 ms | 57 ms | \n| key-binding search, warm | 18.8 ms | 8.3 ms | \n| programs spawned per refresh | 6 | 1 | \n\nI also stopped passing `#{pane_pid}` to the git segment in that commit.\nThat one argument was what sent it hunting through the process table for a suspended nvim, 18.3 ms every second, for a marker I turned out not to miss.\n\n## The rest of the config followed it in Link to heading\n\nThe zsh was 1,495 lines and the Rust is 20,207, with another 1,375 in tests.\nMost of the performance came from that single `#()` call, so the extra lines bought something else: I can dump a lot of zsh scripts into one codebase, and over time I plan to move the other parts of tmux I’ve customised into it.\nHaving multiple scripts isn’t a problem as such, but a single companion for tmux made more sense to me than a directory of scripts, and scripts are hard to configure, since you change the script itself instead of overriding a YAML or TOML file.\nEvery project I have starts with two windows, neovim in the first called “editor” and Claude or Gemini in the second called “ai”, and that’s standard for most projects, but some have “editor” and “ci” with 3-4 panes for linter, tests and dev server, and none of that is easy to write into a script and keep flexible.\n\nOnce a process is sitting there anyway, asking it something else costs close to nothing, so the things I’d shelved for being another spawn got cheap. The daemon holds the rows and the client draws them, which is also why a picker can be tested against a fake backend with no socket in sight.\n\n- `keys` searches every binding I’ve written. tmux lets you put a note on a binding with`-N` and gives you no way to search the notes, so the popup reads them and runs whatever I pick.\n- `cheatsheet` lays the same bindings out in four boxes, most-pressed first, off the usage log the picker was already writing.\n- `project` is one session per project, with live sessions and everything zoxide knows in one list, and`project save` captures the pane layout that project comes back with.\n- `run` picks a command out of shell history into a pane that slides out.\n- `open` takes the URL or the`file:line:col` under the cursor.\n- `theme pick` draws a swatch per theme and applies it on the spot.\n- `shell-init` prints the OSC 133 prompt marks that tmux’s`next-prompt` has wanted since 3.3 and almost nobody wires up.\n- `doctor` puts everything a bug report needs on one screen.\n\nThe project sessions came from a problem I had while context switching. Previously whatever I worked on was connected to some larger piece of work, so even if, in the strict sense, I was working on multiple projects, they were interconnected. Now I work on multiple things that have nothing to do with each other, and separating them into sessions helps me context switch better.\n\nThrough 21 and 22 September 2026 I gave every project its own session with a fixed colour and one key to toggle it, cached the key list so the cheat sheet on `prefix+?` opens without thinking about it, put a second cheat sheet on `prefix+C-c` ordered by what I press rather than alphabetically, and fixed the three bugs that made half my themes unreadable.\nI found a lot of existing tools, but like always I built my own first because it was simple and I knew what I wanted, and if something turns out complicated I see if it’s worth switching to someone else’s maintained tool.\n\nThere are background tasks too, fetching my repositories so the ahead and behind counts mean something, reloading the config when it changes, naming windows after what’s running in them, and telling me a long command finished in a session I wasn’t looking at. All of it is off till a config line turns it on.\n\n## What I didn’t build Link to heading\n\nPlenty of plugins already do pieces of this, so before writing any of it I checked stars and last-push dates.\n`tmux-resurrect` and `tmux-continuum` are still the answer for crash recovery across a whole server, and my autosave shells out to resurrect’s own save script instead of reimplementing it.\nHint-based copy was on my list until I looked: `tmux-fingers` has 1,473 stars, `tmux-thumbs` is already Rust, and the only argument for a third one was that it would share a pattern table with `open`, which isn’t a reason, so `open` will ship a snippet that hands off to thumbs.\n\nIt won’t restore your sessions on its own: it saves them on a timer, and restoring stays on a key I press, because an automatic restore would drop a stale layout over a session I’d already started working in.\n\n## A container to try it in Link to heading\n\nA couple of colleagues once asked for my tmux config and the Go CLI, I assume because of the value they saw me getting out of it. I don’t think they liked it, since nobody followed up, and it was very opinionated and personal to me.\n\ntmux-companion has a Docker image, so you can try it without installing anything.\n\n`docker run --rm -it ghcr.io/lonkar-org/tmux-companion:playground`\nIt has tmux, the binary, the config with every feature turned on, five fake projects and a guided tour through the bindings.\nNothing is mounted from your machine and nothing leaves the container, and it all goes away when you exit.\nEach of the five projects sits in a different git state so the bar has something different to say in every one, and `orchard-api/build.log` holds a compiler error with a path, a line and a column for the copy-mode `o` binding to open.\n\nThe tour is sixteen steps, and each one says what to press, pins itself to a second status line so it’s still in front of you after you’ve switched sessions, and waits for Enter.\n`s` skips a step, `q` drops you into a shell, and `tour` starts it again.\n\nStep one offers nine optional screens on tmux itself: servers and clients, the prefix, how sessions, windows and panes nest, and what detaching does. I stopped at those because I didn’t want to build a tmux tutorial, but since the playground is in Docker anyone can use it, including people who have never used tmux, so I made those screens optional on the first step for anyone who wants the basic concepts. They end by pointing at learntmux.dev, which is 42 tasks against a real tmux in the browser and is better than anything I’d write.\n\nA Nerd Font has to be installed and picked in your own terminal, and step two prints four glyphs so you find out in the first minute.\nIf you start the container from inside tmux, `Ctrl-b` reaches the tmux you were already in and every binding in the tour looks broken, so run it from a terminal that isn’t in a session, or press the prefix twice.\nThe pickers want tmux 3.2 for `display-popup -E` and the bar is happy on 3.0.\n\n## If you’ve got your own pile Link to heading\n\nIf your bar has a few `#()` calls, watch `ps` for a minute and count what it starts, since the cost is in how many programs start and not in what they compute.\n\nI haven’t planned anything next for it beyond moving more of my tmux customisations in. The code is at lonkar-org/tmux-companion, MIT, macOS and Linux.","body_html":"<p>TL;DR I’ve used tmux since 2016 because I can script it. Over ten years my config grew into 1,495 lines of zsh across 13 scripts, and in May 2026 the status bar alone was burning 15.4% of a core on battery. The fix was one <code>#()</code> call instead of six, and the scripts moved into one Rust daemon, tmux-companion. The bar costs 1.8% of a core on a spike and 0.3% idle now.</p>\n<p>I’ve been using tmux since somewhere in 2016 and I’ve never seriously tried to leave.</p>\n<p>People usually sell it on the detach trick, and that part is real. An ssh session drops, the work carries on, I reattach and everything’s where I left it. Sessions on my laptop live for months across sleeps and reboots.</p>\n<h2 id=\"it-s-flexible-link-to-heading\">It’s flexible Link to heading</h2>\n<p>I stayed for a different reason, tmux is scriptable all the way down. Every binding can run a command, every command can read the state of the session, and all of that is out of the box without a single plugin. So I build my own developer experience on top of it, and I’ve customised it heavily.</p>\n<p>Once a binding is in my fingers my attention stays on the problem in front of me instead of on the procedure for reaching it, so the difference between “I need to run this against staging” and running it is pulling one chord instead of four windows and a paste.</p>\n<p>tmux is also cheap. With a dozen sessions open it uses less memory than most IDEs use for one project and sits at nothing when idle, so I can leave sessions running for months without once thinking about what they’re costing me.</p>\n<h2 id=\"the-trail-it-left-in-my-dotfiles-link-to-heading\">The trail it left in my dotfiles Link to heading</h2>\n<p>I can date most of this, because my private dotfiles repo has been running the whole time and git kept the receipts.</p>\n<p>For the first year I didn’t care about dotfiles, vim’s included, because they were small and I didn’t see the point of maintaining a repo for them. I don’t remember which YouTube video it was, but one was about tmux and neovim, and the person showed his dotfiles repo and explained how it grew, and that clicked. If you use a tool for years it grows on you and you want to make it yours, and a dotfiles repo is an easy way to keep track of that, which also shows you your own mistakes and likings.</p>\n<p>First commit in it was the README, second commit was <code>.tmux.conf</code>, 51 lines.\nThat first file already had the things I still set: <code>escape-time 0</code>, windows and panes numbered from 1, <code>renumber-windows on</code>, and a comment about fixing the split path “for tmux 1.9”.</p>\n<p>It also bound <code>prefix a</code> to send the prefix through, which means I was already sshing into something that was itself running tmux.\nI was working on test databases on a VPS box that needed a lot of editing to make them work, the jobs ran for more than 30 minutes, and my connection sometimes dropped, which failed the job 3-4 times, and each time I had to restore the backup and start again.\nThere was also the headache of scp-ing the scripts across, as the box didn’t have access to VCS.</p>\n<p>Powerline arrived the next day, 1 August 2017, and stayed for years. I configured and customised it, then hit its limits: powerline is a DSL over what tmux already gives you raw, and going raw is what let me build what I wanted. The limit I hit was working with JSON and making segments dynamic and runtime-aware, which at that time I couldn’t figure out. So I started playing with my own bash scripts for segments, and that’s when the customising got serious.</p>\n<p>A year later, on 17 August 2018, I wired up tmux-resurrect on <code>C-s</code> and <code>C-r</code>, told it to bring back <code>vim mvim &quot;git log&quot;</code>, turned on <code>@resurrect-capture-pane-contents</code>, and set <code>default-command</code> to <code>reattach-to-user-namespace</code> so that copying out of a pane put something in the macOS clipboard.\nThe same commit added seven lines of mouse wheel bindings, which is what scrolling in tmux cost you in 2018.\nI rarely use resurrect nowadays, mostly because of session autosave and my laptop not dying on me randomly.</p>\n<p>On 1 March 2019 I opened pull request <code>#2</code> against my own dotfiles, “Feature/status-bar-improvements”.\nI knew I’d be committing and reverting things dozens of times, and I wanted it to land as one change I could revert if I didn’t like it in a week.\nI pushed 9 commits, most of them squashed on my machine first.</p>\n<p>I moved from bash to zsh on 31 December 2019.</p>\n<p>Then five years where the tmux config barely moves.\n2021 fixes <code>TERM</code> and reconfigures powerline, 2022 and 2023 and 2024 are one “changes” commit each, and the config sits there working while I get on with the job.\nThe pile grew somewhere else during those years, one zsh file per thing the bar needed to know.</p>\n<h2 id=\"the-pile-link-to-heading\">The pile Link to heading</h2>\n<p>Mine lived in <code>~/.config/tmux/comrades</code> and came to 1,495 lines of zsh across 13 scripts, plus another 294 lines of generators and probes.\nI know <code>.config</code> is meant for configs and not scripts, which I fixed later.\nThat directory held:</p>\n<ul><li>A project switcher.</li><li>A key-binding search.</li><li>A history runner.</li><li>Three scripts for theme picker, preview and generator.</li><li>Something to open whatever’s under the cursor.</li><li>Something to close a session without leaving nvim swap files everywhere.</li><li>zoxide window picker</li><li>a custom window toggler based on process running in window</li></ul>\n<p>I wrote each one on the day something annoyed me.\nThe history command runner was the most frustrating: while working on code I needed to run a script, which I could loop but had to wait until I’d saved the changes, so at first I tried <code>for</code> and <code>read</code> so it ran manually, but then I had to keep switching windows, and if I used a split pane then vim wrapped long lines or I had to scroll, and it wasn’t a good time.\nSo I wrote the history runner and added restart on a key press, so I can run it anytime or view its output to copy from.</p>\n<p>The script that opens vim on a file path found in copy-mode-vi was obvious once I started doing a lot of testing with vim as my editor and my onchange script as an auto runner for anything.</p>\n<p>Not all of it was zsh.\nI also had a Go CLI that did various tasks for me on the command line, and one of them was drawing git status in tmux, which is where <code>gst</code> comes from.\nI don’t remember where I got the inspiration to write it.\nThe first Go version was sloppy and had SQLite embedded in it, which I later replaced with an in-memory cache.</p>\n<p>I never added any of it up till May 2026.</p>\n<h2 id=\"then-i-measured-it-link-to-heading\">Then I measured it Link to heading</h2>\n<p>In May 2026 I noticed my laptop was always running hot, with the CPU high for no reason I could name.\nChrome and Docker were there, I was locked in on Reddit for an hour or so and the cooling fans slowly gained speed.\nSo I quit Chrome and killed Docker Desktop, but CPU utilisation was still at 20%, with nothing running in my sessions.\nI went to Activity Monitor first, then <code>top</code>, then <code>ps</code> to understand the process hierarchy, and the open files in Activity Monitor made it clear it was my CLI and the other scripts, all started by tmux.\nThe rest of the 20% was menubar apps and macOS processes, which I googled and decided not to touch.</p>\n<p>tmux refreshes the status bar on <code>status-interval</code>, once a second here.\nMy bar called six programs to draw one line of text.\nOn battery that was 15.4% of one core, all day, so tmux wasn’t sitting at nothing any more.</p>\n<p>Computing every segment on my bar takes 2.6 ms, but a fork and exec costs 12.4 ms of CPU on my machine, 14.6 as tmux runs it through <code>sh -c</code>, so almost all of it went on starting programs.\nSix of those a second came to 153.77 ms of CPU for every second of wall clock, 80.90 in the tmux server and 72.88 in the spawns.\nThe worst case was worse and had a different cause: the old <code>net</code> segment hit a <code>netstat</code> hang about one call in three, which took a single refresh past 1,100 ms.</p>\n<p>The middle of the bar was the worst of it, since that segment ran once per window instead of once per bar, and tmux’s own <code>window-status-format</code> had been sitting there doing the same job the whole time.\nOn the left, a client count and a glyph that meant “nvim is suspended somewhere”, and the second one walked every process on the machine to decide whether to draw one character.</p>\n<p>The pickers paid the same cost.\nA popup that starts zsh, sources a config and pipes into <code>fzf</code> spends most of its latency before the first frame shows up, and none of that’s the search.</p>\n<h2 id=\"one-process-link-to-heading\">One process Link to heading</h2>\n<p>I ported the Go CLI to Rust.\nI chose Rust because I found it fun, nothing in particular except that I liked its memory management and wanted to write something in it.\nOn 5 June 2026 I pointed the bar at the port, tmux-companion, and deleted 324 lines of zsh in one commit, <code>battery-life.zsh</code>, <code>net-monitor.zsh</code>, <code>check-clients.zsh</code>, <code>tmux-session-name-format.zsh</code>, and <code>window-status.zsh</code> at 154 lines on its own.\nThe bar was still slow, because I’d swapped what ran inside six <code>#()</code> calls without touching the six.</p>\n<p>On 11 August I collapsed the six calls into one.</p>\n<pre><code># one call, not six\nset -g status-right &quot;#(tmux-companion status-right #{pane_current_path})&quot;</code></pre>\n<p>The daemon holds its caches in memory and answers over a unix socket. Every binding that used to start a shell sends it one line of JSON and reads one back. The pickers draw in the client, since the daemon hasn’t got a terminal.</p>\n<div class=\"table-wrap\"><table><thead><tr><th></th><th>before</th><th>after</th></tr></thead><tbody><tr><td>status bar</td><td>153.77 ms/s, 15.4% of a core</td><td>18.43 ms/s, 1.8% on a spike</td></tr><tr><td>status bar, idle</td><td></td><td>0.3%</td></tr><tr><td>worst single refresh</td><td>over 1,100 ms</td><td>57 ms</td></tr><tr><td>key-binding search, warm</td><td>18.8 ms</td><td>8.3 ms</td></tr><tr><td>programs spawned per refresh</td><td>6</td><td>1</td></tr></tbody></table></div>\n<p>I also stopped passing <code>#{pane_pid}</code> to the git segment in that commit.\nThat one argument was what sent it hunting through the process table for a suspended nvim, 18.3 ms every second, for a marker I turned out not to miss.</p>\n<h2 id=\"the-rest-of-the-config-followed-it-in-link-to-heading\">The rest of the config followed it in Link to heading</h2>\n<p>The zsh was 1,495 lines and the Rust is 20,207, with another 1,375 in tests.\nMost of the performance came from that single <code>#()</code> call, so the extra lines bought something else: I can dump a lot of zsh scripts into one codebase, and over time I plan to move the other parts of tmux I’ve customised into it.\nHaving multiple scripts isn’t a problem as such, but a single companion for tmux made more sense to me than a directory of scripts, and scripts are hard to configure, since you change the script itself instead of overriding a YAML or TOML file.\nEvery project I have starts with two windows, neovim in the first called “editor” and Claude or Gemini in the second called “ai”, and that’s standard for most projects, but some have “editor” and “ci” with 3-4 panes for linter, tests and dev server, and none of that is easy to write into a script and keep flexible.</p>\n<p>Once a process is sitting there anyway, asking it something else costs close to nothing, so the things I’d shelved for being another spawn got cheap. The daemon holds the rows and the client draws them, which is also why a picker can be tested against a fake backend with no socket in sight.</p>\n<ul><li><code>keys</code> searches every binding I’ve written. tmux lets you put a note on a binding with<code>-N</code> and gives you no way to search the notes, so the popup reads them and runs whatever I pick.</li><li><code>cheatsheet</code> lays the same bindings out in four boxes, most-pressed first, off the usage log the picker was already writing.</li><li><code>project</code> is one session per project, with live sessions and everything zoxide knows in one list, and<code>project save</code> captures the pane layout that project comes back with.</li><li><code>run</code> picks a command out of shell history into a pane that slides out.</li><li><code>open</code> takes the URL or the<code>file:line:col</code> under the cursor.</li><li><code>theme pick</code> draws a swatch per theme and applies it on the spot.</li><li><code>shell-init</code> prints the OSC 133 prompt marks that tmux’s<code>next-prompt</code> has wanted since 3.3 and almost nobody wires up.</li><li><code>doctor</code> puts everything a bug report needs on one screen.</li></ul>\n<p>The project sessions came from a problem I had while context switching. Previously whatever I worked on was connected to some larger piece of work, so even if, in the strict sense, I was working on multiple projects, they were interconnected. Now I work on multiple things that have nothing to do with each other, and separating them into sessions helps me context switch better.</p>\n<p>Through 21 and 22 September 2026 I gave every project its own session with a fixed colour and one key to toggle it, cached the key list so the cheat sheet on <code>prefix+?</code> opens without thinking about it, put a second cheat sheet on <code>prefix+C-c</code> ordered by what I press rather than alphabetically, and fixed the three bugs that made half my themes unreadable.\nI found a lot of existing tools, but like always I built my own first because it was simple and I knew what I wanted, and if something turns out complicated I see if it’s worth switching to someone else’s maintained tool.</p>\n<p>There are background tasks too, fetching my repositories so the ahead and behind counts mean something, reloading the config when it changes, naming windows after what’s running in them, and telling me a long command finished in a session I wasn’t looking at. All of it is off till a config line turns it on.</p>\n<h2 id=\"what-i-didn-t-build-link-to-heading\">What I didn’t build Link to heading</h2>\n<p>Plenty of plugins already do pieces of this, so before writing any of it I checked stars and last-push dates.\n<code>tmux-resurrect</code> and <code>tmux-continuum</code> are still the answer for crash recovery across a whole server, and my autosave shells out to resurrect’s own save script instead of reimplementing it.\nHint-based copy was on my list until I looked: <code>tmux-fingers</code> has 1,473 stars, <code>tmux-thumbs</code> is already Rust, and the only argument for a third one was that it would share a pattern table with <code>open</code>, which isn’t a reason, so <code>open</code> will ship a snippet that hands off to thumbs.</p>\n<p>It won’t restore your sessions on its own: it saves them on a timer, and restoring stays on a key I press, because an automatic restore would drop a stale layout over a session I’d already started working in.</p>\n<h2 id=\"a-container-to-try-it-in-link-to-heading\">A container to try it in Link to heading</h2>\n<p>A couple of colleagues once asked for my tmux config and the Go CLI, I assume because of the value they saw me getting out of it. I don’t think they liked it, since nobody followed up, and it was very opinionated and personal to me.</p>\n<p>tmux-companion has a Docker image, so you can try it without installing anything.</p>\n<p><code>docker run --rm -it ghcr.io/lonkar-org/tmux-companion:playground</code>\nIt has tmux, the binary, the config with every feature turned on, five fake projects and a guided tour through the bindings.\nNothing is mounted from your machine and nothing leaves the container, and it all goes away when you exit.\nEach of the five projects sits in a different git state so the bar has something different to say in every one, and <code>orchard-api/build.log</code> holds a compiler error with a path, a line and a column for the copy-mode <code>o</code> binding to open.</p>\n<p>The tour is sixteen steps, and each one says what to press, pins itself to a second status line so it’s still in front of you after you’ve switched sessions, and waits for Enter.\n<code>s</code> skips a step, <code>q</code> drops you into a shell, and <code>tour</code> starts it again.</p>\n<p>Step one offers nine optional screens on tmux itself: servers and clients, the prefix, how sessions, windows and panes nest, and what detaching does. I stopped at those because I didn’t want to build a tmux tutorial, but since the playground is in Docker anyone can use it, including people who have never used tmux, so I made those screens optional on the first step for anyone who wants the basic concepts. They end by pointing at learntmux.dev, which is 42 tasks against a real tmux in the browser and is better than anything I’d write.</p>\n<p>A Nerd Font has to be installed and picked in your own terminal, and step two prints four glyphs so you find out in the first minute.\nIf you start the container from inside tmux, <code>Ctrl-b</code> reaches the tmux you were already in and every binding in the tour looks broken, so run it from a terminal that isn’t in a session, or press the prefix twice.\nThe pickers want tmux 3.2 for <code>display-popup -E</code> and the bar is happy on 3.0.</p>\n<h2 id=\"if-you-ve-got-your-own-pile-link-to-heading\">If you’ve got your own pile Link to heading</h2>\n<p>If your bar has a few <code>#()</code> calls, watch <code>ps</code> for a minute and count what it starts, since the cost is in how many programs start and not in what they compute.</p>\n<p>I haven’t planned anything next for it beyond moving more of my tmux customisations in. The code is at lonkar-org/tmux-companion, MIT, macOS and Linux.</p>","headings":[{"level":2,"text":"It’s flexible Link to heading","id":"it-s-flexible-link-to-heading"},{"level":2,"text":"The trail it left in my dotfiles Link to heading","id":"the-trail-it-left-in-my-dotfiles-link-to-heading"},{"level":2,"text":"The pile Link to heading","id":"the-pile-link-to-heading"},{"level":2,"text":"Then I measured it Link to heading","id":"then-i-measured-it-link-to-heading"},{"level":2,"text":"One process Link to heading","id":"one-process-link-to-heading"},{"level":2,"text":"The rest of the config followed it in Link to heading","id":"the-rest-of-the-config-followed-it-in-link-to-heading"},{"level":2,"text":"What I didn’t build Link to heading","id":"what-i-didn-t-build-link-to-heading"},{"level":2,"text":"A container to try it in Link to heading","id":"a-container-to-try-it-in-link-to-heading"},{"level":2,"text":"If you’ve got your own pile Link to heading","id":"if-you-ve-got-your-own-pile-link-to-heading"}]}}