Setup

Which window is which.

Run six agents in six terminal windows and every one of them is the same black rectangle. This page is the fix — from a five-minute statusline to a launchd watcher that paints every window by project. All of it built by Claude Code, including the part where it disassembled the terminal to check what actually works.

Screenshots are reconstructions — same system, project names swapped for decoys. The real ones are client work.

Jump to section tap to open

The glance test

Chapter 20 gets you to six parallel Claude Code sessions. What it doesn't tell you is what your screen looks like when you're there: six identical dark rectangles. Same background, same font, same prompt. The only difference is a title bar you have to squint at — and half the time the title is whatever the agent last renamed itself to, not the project.

Here's the failure mode, and it's not hypothetical: you alt-tab into what you think is the staging repo and type a destructive command into production's shell. Or the cheaper, daily version — an agent finished four minutes ago and is sitting there waiting for your approval, and you didn't notice, because a waiting window looks exactly like a working window. You're paying for parallel agents and losing the parallelism to which one was this again?

The test your setup has to pass is a glance from across the room: which window is which project, and which one needs me right now. Not read — glance. Color does that. Text doesn't.

zsh — 80×24 zsh — 80×24 zsh — 80×24 zsh — 80×24 zsh — 80×24 zsh — 80×24
Before: six sessions, six projects, zero information. One of these is production. Good luck.
🚢 harbor — ◐ Tracing a flaky webhook retry 🧵 loom — ✳ Waiting: approve the migration? 🔥 ember — ◑ Refactoring the image pipeline 🛰️ atlas — ◐ Writing the release notes 🌿 fern — ✳ Idle — 133 tests green 💎 quartz — ◑ Auditing the auth flow
After: same six windows, painted. Background tint = project. Emoji + label in the title. The cursor color matches the family. in the title means it's waiting for you. You can read this from the couch.

The four layers

Terminal customization sounds like one thing. It's four independent layers, and knowing which layer owns what saves you from fighting the wrong one:

Layer Owns Where it lives
The terminal appProfiles, fonts, tabs, splits, GPU rendering, which escape codes it honorsiTerm2 / WezTerm / Ghostty / Terminal.app settings
The shell promptThe line you type on — git branch, exit codes, timingStarship, Powerlevel10k, or hand-rolled PS1
Escape codes (OSC)Live recolor of background, foreground, cursor, title — from any process, any timeprintf sequences; the layer scripts talk to
Claude Code itselfStatusline, its own UI theme, window title while working, notification channel~/.claude/settings.json + /statusline, /config, /rename

The layer most people never touch is the third one, and it's the one that makes per-project painting possible. OSC (Operating System Command) sequences are how a program tells the terminal "change your background to this hex, right now." No profile switching, no restart:

# paint this window: background, foreground, cursor
printf '\e]11;#1b1025\a'   # OSC 11 — background
printf '\e]10;#f3edf9\a'   # OSC 10 — default text
printf '\e]12;#d19cff\a'   # OSC 12 — cursor
printf '\e]0;🧵 loom\a'    # OSC 0  — window title

# undo everything
printf '\e]111\a\e]110\a\e]112\a'

Run that first block and your window is purple before you finish reading this sentence. That's the entire trick. Everything else on this page is deciding which window gets which color, automatically, and keeping it that way.

Terminal options, honestly

Chapter 20 said skip Terminal.app, and then I built this whole system on Terminal.app. Both are right, and the difference matters: Chapter 20 is about one window with many panes — tmux, splits, scrollback search — and Terminal.app is genuinely bad at that. This page is about many windows, one session each, which is the other legitimate fleet shape: every agent gets its own alt-tabbable, ping-able, paintable window. For that shape, Terminal.app turns out to be fine — it honors every escape code this system needs, it's already on the machine, and its blandness stops mattering the moment your watcher is doing the painting.

The honest matrix, from actually testing what each one honors:

Terminal Per-window color story Take
Terminal.appOSC 0/10/11/12 honored live; 256-color onlyUnderrated for one-window-per-agent. This system runs on it.
iTerm2All of the above + tab colors, badges, triggers, per-profile everythingThe most machinery out of the box. If you live in tabs, start here.
WezTermEverything scriptable in Lua — tab bar, colors, per-pane logicThe programmable choice. Your fleet-paint logic can live inside the terminal config.
GhosttyFast, truecolor, good OSC coverage, per-surface configsBest raw feel of the new generation. Younger ecosystem.
kitty / Alacrittykitty: remote-control API for recoloring; Alacritty: minimal, config-file themeskitty's kitten @ set-colors is fleet paint as a first-class feature.
WarpBlocks, AI features, themes — but opinionated about owning the renderingIts own world. If you're in it, use its native theming, not OSC.

The real advice: the terminal app matters less than you think, because the painting layer is terminal-agnostic. OSC 10/11/12 works everywhere that matters. Pick the app for the ergonomics you actually use — then let the escape codes do the identity work.

What Claude Code gives you out of the box

Before building anything custom, take the four free wins. Claude Code ships its own customization surface, and most people run it at defaults:

1. The statusline. A shell script that runs on every prompt render and prints one line under the input box. Claude Code pipes it JSON on stdin — current directory, model, context usage, session cost — and renders whatever your script prints. Type /statusline and it will build one for you; or wire it manually:

// ~/.claude/settings.json
{
  "statusLine": { "type": "command", "command": "~/.claude/scripts/statusline.sh" }
}
#!/usr/bin/env bash
# ~/.claude/scripts/statusline.sh — minimal version
input=$(cat)
dir=$(echo "$input"  | jq -r '.workspace.project_dir // .cwd' )
model=$(echo "$input" | jq -r '.model.display_name')
pct=$(echo "$input"  | jq -r '(.context // empty) | tostring' 2>/dev/null)
proj=$(basename "$dir" | tr '[:lower:]' '[:upper:]')
printf '\033[1;38;5;16;48;5;250m %s \033[0m · %s' "$proj" "$model"
🚢 HARBOR ⎇ feat/checkout-retries · Fable 5 · ctx 29% · $12.40 🏠 HOME · Fable 5 · ctx 4% · $0.62
The statusline: project badge, branch, model, context burn, session cost. Second row — the home directory gets its own identity and, deliberately, no branch. One glance answers "where am I and what is this costing."

2. The theme. /config lets you pick Claude Code's own UI theme, and it accepts custom theme files — JSON in ~/.claude/themes/ that override specific UI colors. This matters more than it sounds; see the gotchas section for why a dark-window setup can make Claude Code's secondary text unreadable without one.

3. The window title. Claude Code continuously writes the terminal title while it works — a spinner glyph plus a summary of what it's doing, then when idle. /rename my-task pins your own name into it. Your terminal's title bar is already a status display; most people never look up.

4. The bell. Set "preferredNotifChannel": "terminal_bell" in settings and Claude Code rings the terminal bell when it needs you. Configure the terminal to badge-not-beep and you get a silent Dock badge on the exact window that's waiting. With eight sessions, this is the difference between polling your own fleet and being paged by it.

Fleet paint — the full system

The free wins identify the session inside the window. They don't pass the glance test — for that, the whole window has to carry the project. Here's the move: a small watcher that discovers every live Claude Code session, resolves its project, and paints the window it lives in. Four pieces:

The registry. Each session, on start, writes a small JSON file — pid, tty, cwd, state — into ~/.claude/sessions/ (a session-start hook does this; a hook on state changes keeps waiting fresh). The registry is the source of truth for what's running where. Everything else just reads it.

The palette. One JSON file mapping project-folder names to a look — background, foreground, cursor, a 256-color badge index for the statusline, a label, an emoji:

// ~/.claude/project-colors.json
{
  "harbor": { "label": "Harbor", "bg": "#001a1d", "fg": "#e5f4f5",
              "cursor": "#00d0dd", "ansi": 23, "emoji": "🚢" },
  "loom":   { "label": "Loom",   "bg": "#1b1025", "fg": "#f3edf9",
              "cursor": "#d19cff", "ansi": 91, "emoji": "🧵" },
  "ember":  { "label": "Ember",  "bg": "#260d0d", "fg": "#fbeceb",
              "cursor": "#ff9592", "ansi": 124, "emoji": "🔥" },
  "_fallback": [ /* muted grey family for unknown projects, picked by hash */ ]
}

The colors aren't picked by vibes. They're generated — six hue families in OKLCH at two lightness tiers, so backgrounds sit at equal perceived darkness, text holds ~15:1 contrast on every one of them, and each pair stays distinguishable under the two most common forms of color-blindness. The generator prints a validation table — contrast ratios, pairwise color distance, simulated deuteranopia — and refuses vibes. Related projects share a hue family across tiers: two shades of cyan are two products of the same company. Unknown projects fall back to a hashed pick from a muted grey family that's deliberately disjoint from the configured colors — an unpainted project never impersonates a painted one.

The watcher. A launchd-managed loop (a few hundred lines of Python, ~1% CPU) that every couple of seconds: reads the registry, cross-checks against ps that each pid is a live, interactive Claude Code on a real tty, resolves the project from the cwd basename, and writes the OSC paint — background, text, cursor — straight to that session's tty. Title gets the emoji + label, and a marker when the session is waiting for input. Windows repaint when a session moves, reset when it dies, and the palette hot-reloads on file change — edit a hex, watch the fleet recolor.

The statusline, upgraded. The same script from the section above, except it imports the same palette. That's the detail that makes the system feel engineered instead of decorated: the badge color inside the window and the paint of the window come from one file. There is no way for them to disagree.

The launchd wiring, so it survives reboots and crashes:

<!-- ~/Library/LaunchAgents/com.you.fleet-paint.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
  <key>Label</key><string>com.you.fleet-paint</string>
  <key>ProgramArguments</key>
  <array><string>/usr/bin/python3</string>
         <string>/Users/you/.claude/scripts/fleet-paint.py</string>
         <string>loop</string></array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
</dict></plist>

The gotchas that cost a day

Everything above sounds like an afternoon. The afternoon version works on the happy path and then flakes in ways that make you distrust it. These are the failures that were actually hit, in the order they were hit:

  • The pty write buffer is 1 KiB. Writing escape codes to someone else's tty can short-write or hit EAGAIN. If you give up mid-sequence, the unterminated OSC swallows the agent's own output until something closes it — the bug looks like Claude Code eating text. Bounded retry, and never emit a sequence you can't finish.
  • Dim text has a contrast ceiling. Terminal.app renders "dim" as 40% text over 60% background — on a dark background the best contrast dim text can reach is about 3.8:1. Your palette's foregrounds must be near-white so that dimmed derivatives stay readable. This is why the generator targets ~15:1 for normal text: it's buying headroom for the dim case.
  • Claude Code's own greys don't follow OSC. In a 256-color terminal, CC picks explicit palette indices for its secondary text. Repaint the background and those indices don't move — you can end up with dark-grey-on-dark, functionally invisible. The fix is a custom CC theme that pins secondary text to lighter indices. Corollary: don't force COLORTERM=truecolor in a terminal that doesn't really do truecolor; you'll trade wrong-greys for banded-everything.
  • Pids get reused. A registry entry can outlive its session, and the pid can come back as some other process. Cross-check process start time against the registry's — and only distrust it in one direction, because a resumed session legitimately starts before its registry file.
  • Never trust a color you read back. AppleScript will happily report a dynamic background as pure white. "Restore" from that read-back and you've painted a window permanently white. Reset with OSC 111/110/112, never with a remembered value.
  • Emoji in titles: one codepoint, no variation selector. Multi-codepoint emoji and FE0F variants trigger width bugs in title bars. Pick emoji that are emoji-presentation by default and move on.
  • A half-written registry file isn't a dead session. Require two consecutive misses before declaring a session gone and resetting its window — or every registry write races your watcher into a flicker.

None of these are exotic. All of them are invisible until you run the system for real, across sleep/wake cycles, session resumes, and eight concurrent windows. Which is the setup for the next section.

Build it with Claude Code

Here's the part that makes this page belong in this book: I didn't write this system. I described it. The whole stack — registry hooks, palette generator with the color-science validation, watcher with every guard in the gotchas list, statusline, launchd wiring — was built by Claude Code across two evening sessions. The first evening produced the naive version. The second evening was "make it flawless, no silent failures" — and that instruction is what produced the gotchas list above, because the agent went and found the failure modes I hadn't hit yet.

Two moments from that build are the actual lesson. First: when it wasn't sure which escape codes the terminal honored, it didn't guess from training data — it disassembled the terminal binary and checked. The OSC support matrix in this page comes from that, not from a blog post. Second: when I asked for the palette, it didn't pick colors — it wrote a generator that proves the colors: contrast table, pairwise distance, color-blindness simulation, printed as a report. Verification as a deliverable, same as every other chapter.

The prompt shape, if you want the same build:

I run 5–8 Claude Code sessions in parallel, one terminal window each.
I want every window painted by project so I can tell them apart at a glance.

Build me:
1. A session registry (~/.claude/sessions/) maintained by CC hooks.
2. A palette file mapping project folders → bg/fg/cursor/badge/emoji.
   Generate the colors in OKLCH: 6 hue families × 2 tiers, validate
   contrast and color-blind separability, print the proof.
3. A launchd watcher that paints each session's tty via OSC 10/11/12
   and sets the title to emoji + project + a waiting marker.
4. A statusline that reads THE SAME palette file for its badge.

Then: harden it. Assume ptys short-write, pids get reused, registry
files get half-written, and the terminal lies when asked its colors.
No silent failures — every skipped session gets a printed reason.

Note what the prompt does and doesn't do. It specifies the architecture (registry → palette → watcher → statusline) because that's an opinion worth having. It does not specify the escape codes, the color math, or the failure handling — that's the part the agent is better at exhausting than you are. And the last paragraph is the one that matters: "harden it, assume everything lies, no silent failures" is the difference between a demo and a system you stop thinking about.

Do this Monday

The ladder, cheapest rung first. Each rung is independently worth having:

  1. Five minutes: type /statusline in Claude Code and let it build you a statusline with project name, branch, context %, and cost. You now know where you are and what it's costing, in every session.
  2. Ten minutes: set "preferredNotifChannel": "terminal_bell", and set your terminal to badge instead of beep. Your fleet now pages you instead of you polling it.
  3. Twenty minutes: the OSC one-liners from the layers section, wrapped in two shell functions — paint <color> and unpaint — that you call manually in important windows. Production gets red. That alone has prevented real mistakes.
  4. One evening: paste the prompt from the previous section and build the full watcher. Then spend the second evening on "harden it." The second evening is the one that makes it permanent.

The through-line of the whole page: with a fleet of agents, your attention is the scarce resource, and the terminal is its dashboard. Every second you spend figuring out which window you're in is overhead on every single task you'll ever run. Paint once, glance forever.

Stay close

The next edition lands when this list says it does.

No course. No paywall. Operator playbooks weekly. 10K+ subscribers.