---
title: "One Theme, Every Tool: monotheme"
date: 2026-06-24
slug: building-monotheme
description: "I change color themes the way other people change desktop wallpapers. The problem: my \"environment\" isn't one app, it's twenty. Ghostty, tmux, Neovim, VSCode,…"
---

I change color themes the way other people change desktop wallpapers. The problem: my "environment" isn't one app, it's twenty. Ghostty, tmux, Neovim, VSCode, Cursor, Zed, btop, lazygit, yazi, fzf, bat, opencode, Claude Code, even the macOS accent color and Raycast. Switching themes meant hand-porting the same six colors into twenty config formats, getting bored halfway, and living with a half-themed setup forever.

So I built [monotheme](https://github.com/eduwass/monotheme): one source of truth, one command, everything reskins at once.

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

```bash
theme set <name>
```

Repo: **[github.com/eduwass/monotheme](https://github.com/eduwass/monotheme)**.

## The canonical format problem

The naive approach is to invent your own theme schema — a little YAML file with `background`, `foreground`, eight ANSI colors — and generate everything from that. I tried. It falls apart the moment a real editor wants syntax highlighting, because "the color of a comment" isn't one color. It's the resolution of a TextMate scope like `comment.line.double-slash.ts` against ~140 scope rules with fallbacks.

A 16-color palette can't represent that. So I flipped it: the **canonical format is the full VSCode theme** — the fat one, all 140-odd TextMate scopes intact. That's the source of truth. Everything else *projects down* from it.

- A dumb tool (fzf, btop) gets the normalized 16-ANSI palette.
- A smart tool (Neovim, bat) gets the full scope resolution.

Keep the richest representation as canonical, lose nothing, throw away detail only at the edges where the target can't use it anyway.

## The pipeline

Four stages, each boring on purpose:

```
load → project → adapter → target
```

`load` parses the VSCode theme JSON. `project` derives the normalized palette — bg, fg, ANSI 16, accents — and exposes `resolveToken`, which maps any TextMate scope to its color through a matcher algorithm I ported from VSCode's own resolver. Then each **adapter** renders that into one tool's native format, and each **target** writes the file to the right place and reloads the running app.

The split between adapter (what to write) and target (where to put it and how to reload) is the part that made the whole thing extensible. Adding a new tool is one adapter + one target, and it never touches the core.

## Live reload

`theme set` doesn't just write configs — it reloads the apps that are already running. tmux gets a `source-file`, Ghostty re-reads, Neovim instances get poked. The win isn't really the command, it's that there's no follow-up: no "now restart your terminal," no stale half-themed windows. You run one command and the whole screen flips.

## Install

Needs [Bun](https://bun.sh).

```bash
git clone https://github.com/eduwass/monotheme
cd monotheme && bun install && bun link
```

That puts a `theme` command on your PATH:

```bash
theme list          # installed + bundled themes
theme set <name>    # project to every tool + live-reload
theme current       # active theme
theme check         # self-check, no writes
```

Drop `theme init` in your shell rc so a fresh shell re-applies the active theme, and you're done.

It won't touch tools you don't have — if Zed isn't installed, that target is skipped. Start with one or two tools, add adapters as you go.

Repo: **[github.com/eduwass/monotheme](https://github.com/eduwass/monotheme)**.
