---
title: "A Raycast-Style Command Palette for tmux"
date: 2026-05-13
slug: tmux-palette
description: "<video src=\"demo.mp4\" autoplay loop muted playsinline style=\"width:100%;border-radius:8px\"></video>"
---

<video src="demo.mp4" autoplay loop muted playsinline style="width:100%;border-radius:8px"></video>

Tweeted a screenshot of this earlier today, people asked for the code, open-sourced it the same day: **[github.com/eduwass/tmux-palette](https://github.com/eduwass/tmux-palette)**.

tmux has a million commands and zero discoverability. You either know the bind or you don't. Raycast fixed that for macOS apps. This does the same for tmux: hit a key, fuzzy-find, run.

## The opentui detour

First version used [opentui](https://github.com/anomalyco/opentui) — flexbox layout in the terminal, theming, mouse events. Tutorial-clean code, ~50 lines per palette.

It shipped. It was slow.

```
=== bun no-op ===              0.00 s
=== bun + opentui import ===   0.19 s
```

opentui ships native Yoga bindings. They deserialize on import. Every popup open paid 190ms loading them before drawing a single character. Felt like a 200ms hiccup every time. `bun build --compile` doesn't help — it bundles the runtime, not the imports.

## The rewrite

I dropped the framework. The renderer now writes ANSI escape codes straight to stdout, like the prototype I started with.

```ts
stdout.write("\x1b[?2026h\x1b[?25l\x1b[H" + frame + "\x1b[?2026l")
```

That's the whole render call. `?2026h/l` is synchronized output — tmux 3.4+ swaps the frame atomically, so holding the arrow key doesn't flicker. Cold start went from 190ms to 20ms.

The framework gained ~100 lines for manual width/scroll/mouse handling. Each palette stayed the same size. Net wash on lines, 10× speedup.

## User-land config

Customization lives in `~/.config/tmux-palette/`. Four JSON files, one job each. Add your own commands without forking:

```json
// ~/.config/tmux-palette/commands.json
[
  { "icon": "", "title": "Toggle Diff Viewer", "category": "Tools",
    "action": { "tmux": "run-shell '~/scripts/diff-viewer.sh'" } }
]
```

`shortcuts.json` overrides the right-side label. `theme.json` overrides colors. `aliases.json` adds chips. The source-level `definePalette()` API is still there for power users, but nobody should have to fork to label a key.

## The dispatch trick

tmux's `confirm-before` and `command-prompt` need stdin. If you run them *inside* the popup, they hang — the popup owns stdin.

So the palette doesn't run the command. It writes the encoded command to a tempfile and exits. The bash wrapper reads the file *after* `display-popup` returns and runs it in the host context. Prompts get stdin, users press y/n, world keeps spinning.

```bash
tmux display-popup -E "TMUX_PALETTE_CMD='$CMD_FILE' bun src/cli.ts"
[ -s "$CMD_FILE" ] && eval "tmux $(cat "$CMD_FILE")"
```

Embarrassingly long to figure out the first time. Sub-100-byte fix.

## Install

```bash
git clone https://github.com/eduwass/tmux-palette ~/Sites/tmux-palette
cd ~/Sites/tmux-palette && bun install
```

Then in `.tmux.conf`:

```tmux
bind -n C-Space run-shell "~/Sites/tmux-palette/bin/tmux-palette.sh"
```

Reload, hit Ctrl+Space.

The README has an agent-handoff prompt — paste it into Claude Code / Codex / opencode / Cursor and it does the install, asks you which key to bind, optionally reads your terminal config and writes a matching theme. Onboarding via "paste this into your agent" feels right for 2026.

Repo: **[github.com/eduwass/tmux-palette](https://github.com/eduwass/tmux-palette)**.
