Colours each iTerm2 tab according to the project its $PWD is in, so tabs group
visually by what you're working on. Every shell anywhere inside ~/dev/mile-os gets
the same colour; a shell in ~/dev/mile-rails gets a different one.
- Script:
~/.oh-my-zsh/custom/iterm-tab-color.zsh - Checksum:
9f11e053413d59f0f15146f2d2038a477194f44c1a541d4c277c0d6f8f98aef9 - Set up: 2026-08-18
iTerm2 supports a proprietary OSC escape sequence that sets the current tab's colour:
ESC ] 6 ; 1 ; bg ; red ; brightness ; <0-255> BEL
ESC ] 6 ; 1 ; bg ; green ; brightness ; <0-255> BEL
ESC ] 6 ; 1 ; bg ; blue ; brightness ; <0-255> BEL
ESC ] 6 ; 1 ; bg ; * ; default BEL # clears it
Anything that can print can drive it, so a zsh chpwd hook is enough. On every
directory change the hook works out which project the new $PWD belongs to and emits
that project's colour.
Project detection, in order:
- Nearest ancestor containing
.git(tested with-e, not-d, so git worktrees and submodules — where.gitis a file — match too). The walk stops at$HOMEso a stray~/.gitcan't swallow everything. - Otherwise, the first directory below a workspace root (
_ITC_WORKSPACES, default~/dev), so non-repo directories like~/dev/playstill get their own colour instead of all of~/devsharing one. - Otherwise, the first directory under
$HOME. - Otherwise (
/etc,/, …) no project — the tab colour resets to the profile default.
Colour choice is a djb2 hash of the project directory's basename, modulo a 14-colour palette. No configuration needed: new repos get a stable colour automatically.
| Project | Colour | RGB |
|---|---|---|
~/dev/mile-core |
lime | 95 160 50 |
~/dev/mile-os |
magenta | 185 65 165 |
~/dev/mile-os-js |
cyan | 35 140 180 |
~/dev/mile-rails |
rose | 200 70 110 |
~/dev/play |
green | 40 150 85 |
~/dev (loose files) |
blue | 45 110 205 |
- The palette is deliberately not in rainbow order. Consecutive entries jump ~150°
of hue. Hashes of similar names frequently land on adjacent indices, so rainbow
ordering makes near-collisions look identical. A first attempt with a 12-colour
rainbow palette put
mile-osandplayon the same olive. - Palette colours are mid-tone. iTerm2 derives the tab's label colour from the background, so very light or very dark tints make the text unreadable.
- No forks on the hot path.
_itc_project_rootand_itc_hashreturn via globals rather than stdout, and the hash is computed in zsh arithmetic rather than shelling out. Measured at 0.062 ms percd. - Palette size was not tuned to fit the current project names. That would look clever and silently break the moment a new repo appeared.
Pin a project to a specific colour — if the hash picks one you dislike, or two projects you use together collide. Keys are the directory basename:
_ITC_OVERRIDES[mile-rails]='178 40 55'Add a workspace root — it's an array:
typeset -ga _ITC_WORKSPACES=( $HOME/dev $HOME/work )One-off tabs, no config:
tabcolor 200 60 60 # set
tabcolor off # clearThe next cd across a project boundary re-asserts automatic control.
- Only updates on
cdand at shell start. A tab parked invimorsshfor an hour keeps whatever colour it had. sshleaves the colour set. The remote shell doesn't emit anything, so the tab keeps the local project's colour for the session. Fixable with apreexechook if it becomes annoying.- The "Minimal" tab theme tints the whole title bar with only the active tab's colour, instead of showing every tab's colour side by side. Settings → Appearance → General → Theme must be Compact or Regular.
- Hash collisions are possible across 14 colours — inherent to stateless hashing.
The
_ITC_OVERRIDEStable above is the one-line fix.
- iTerm2. The escape sequence is iTerm2-specific. The guard on line 11 makes the file an inert no-op in Terminal.app, VS Code, Ghostty, Linux and CI, so it's safe to deploy everywhere unconditionally.
- zsh.
add-zsh-hookis stock zsh. - oh-my-zsh is optional — it only provides the auto-sourcing. The script is
self-contained and runs fine under
zsh -f.
1. Copy the file over
scp ~/.oh-my-zsh/custom/iterm-tab-color.zsh other-machine:~/.oh-my-zsh/custom/2. Make sure it gets sourced
-
With oh-my-zsh: nothing to do —
oh-my-zsh.sh:211globs$ZSH_CUSTOM/*.zsh. -
Without: put it anywhere (e.g.
~/.config/zsh/iterm-tab-color.zsh) and add to.zshrc, after any framework init so nothing later clobberschpwd_functions:source ~/.config/zsh/iterm-tab-color.zsh
3. Adjust _ITC_WORKSPACES if projects don't live in ~/dev. This is the only
machine-specific line. Git repos are found wherever they are, so this only affects
non-repo directories.
4. Check the tab theme isn't Minimal (see Limitations).
5. Open a new tab. Existing tabs need source ~/.oh-my-zsh/custom/iterm-tab-color.zsh
to pick it up.
The colour is a pure function of the project basename and the palette, so the same repo gets the same colour on every machine with no syncing — but only while the palette's contents and order are byte-identical. Reorder or add one entry on a machine and every colour there shifts. Compare with:
shasum -a 256 ~/.oh-my-zsh/custom/iterm-tab-color.zsh
# 9f11e053413d59f0f15146f2d2038a477194f44c1a541d4c277c0d6f8f98aef9Tweak the palette on one machine and re-copy, rather than editing both.
~/.oh-my-zsh is a clone of upstream ohmyzsh and its .gitignore line 2 is custom/,
so anything in there is invisible to git and can't be committed. There's no dotfiles
repo on this machine, and .zshrc / .zprofile are plain files rather than symlinks.
So reproducing this is a manual scp plus an edit each time, and a lost laptop loses it.
A small ~/dotfiles repo (holding .zshrc, .zprofile and this script, symlinked into
place) would reduce the whole procedure to git clone && ./install.
One bug was found and fixed during testing: inside double quotes zsh takes $'...'
literally, so ${seq//$'\e'/$'\e\e'} was emitting the literal text $'\e\e' into the
tmux passthrough. The ESC is now held in an $esc variable.
Verbatim copy of ~/.oh-my-zsh/custom/iterm-tab-color.zsh, in case the original is lost:
# Colour the iTerm2 tab based on the current project directory.
#
# Every shell whose $PWD is inside a given project gets the same tab colour, so
# tabs group visually by what you're working on. Colours are derived by hashing
# the project directory name, so new repos get a stable colour with no config.
#
# Auto-sourced by oh-my-zsh via $ZSH_CUSTOM/*.zsh.
# Only iTerm2 understands these escapes. LC_TERMINAL survives ssh; TERM_PROGRAM
# is what a local shell sees.
[[ $TERM_PROGRAM == iTerm.app || $LC_TERMINAL == iTerm2 ]] || return 0
# Directories whose immediate children are treated as projects even when they
# aren't git repos.
typeset -ga _ITC_WORKSPACES=( $HOME/dev )
# Mid-tone and mutually distinguishable: iTerm2 derives the tab's label colour
# from this background, so very light or very dark tints read badly.
#
# Deliberately NOT in rainbow order. Hashes of similar names often land on
# adjacent indices, so consecutive entries jump ~150 degrees of hue to keep
# near-collisions visually distinct.
typeset -ga _ITC_PALETTE=(
'178 40 55' # crimson
'40 150 85' # green
'140 80 200' # violet
'205 85 30' # orange
'30 150 130' # teal
'185 65 165' # magenta
'190 140 25' # amber
'35 140 180' # cyan
'200 70 110' # rose
'150 165 35' # olive
'45 110 205' # blue
'140 105 75' # brown
'95 160 50' # lime
'95 90 205' # indigo
)
# Escape hatch: pin a project to a colour when the hash picks one you dislike,
# or when two projects you use together happen to collide. Keys are the project
# directory's basename.
# _ITC_OVERRIDES[mile-rails]='178 40 55'
typeset -gA _ITC_OVERRIDES=()
# _itc_emit <r> <g> <b> — set the tab colour
# _itc_emit — clear it, reverting to the profile default
_itc_emit() {
local seq esc=$'\e'
if (( $# == 3 )); then
seq=$'\e]6;1;bg;red;brightness;'$1$'\a'
seq+=$'\e]6;1;bg;green;brightness;'$2$'\a'
seq+=$'\e]6;1;bg;blue;brightness;'$3$'\a'
else
seq=$'\e]6;1;bg;*;default\a'
fi
if [[ -n $TMUX ]]; then
# tmux eats unrecognised OSC; wrap in DCS passthrough with ESC doubled.
# $esc via a variable: inside double quotes zsh takes $'...' literally.
printf '\ePtmux;%s\e\\' "${seq//$esc/$esc$esc}"
else
printf '%s' "$seq"
fi
}
# Sets $_itc_reply to the nearest enclosing project for $PWD, empty if none.
# Returns via a global rather than stdout so the cd hook doesn't fork.
_itc_project_root() {
local d=$PWD ws
_itc_reply=
# 1. Nearest ancestor holding a .git — -e not -d, so worktrees and submodules
# (where .git is a file) match too. Stop at $HOME so a stray ~/.git can't
# swallow everything.
while [[ -n $d && $d != / && $d != $HOME ]]; do
[[ -e $d/.git ]] && { _itc_reply=$d; return }
d=${d:h}
done
# 2. First component below a workspace root, so non-repo dirs like ~/dev/play
# still get their own colour instead of all of ~/dev sharing one.
for ws in $_ITC_WORKSPACES; do
if [[ $PWD == $ws/* ]]; then
d=${PWD#$ws/}
_itc_reply=$ws/${d%%/*}
return
fi
done
# 3. First component under $HOME.
if [[ $PWD == $HOME/* ]]; then
d=${PWD#$HOME/}
_itc_reply=$HOME/${d%%/*}
fi
# 4. Outside $HOME entirely (/etc, /, ...) — no project, reply stays empty.
}
# djb2. Sets $_itc_hashval; in-shell and fork-free, as this runs on every cd.
_itc_hash() {
local s=$1 c i
_itc_hashval=5381
for (( i = 1; i <= ${#s}; i++ )); do
c=${s[i]}
(( _itc_hashval = (_itc_hashval * 33 + #c) % 4294967296 ))
done
}
_itc_update() {
local -i idx
local name root
local -a rgb
_itc_project_root
root=$_itc_reply
[[ $root == $_ITC_LAST_ROOT ]] && return
_ITC_LAST_ROOT=$root
if [[ -z $root ]]; then
_itc_emit
return
fi
name=${root:t}
if [[ -n ${_ITC_OVERRIDES[$name]} ]]; then
rgb=( ${=_ITC_OVERRIDES[$name]} )
else
_itc_hash $name
(( idx = _itc_hashval % $#_ITC_PALETTE + 1 ))
rgb=( ${=_ITC_PALETTE[idx]} )
fi
_itc_emit $rgb
}
# Manual override for one-off tabs: `tabcolor 200 60 60`, `tabcolor off`.
# The sentinel makes the next cd out of this project re-assert automatic control.
tabcolor() {
if [[ $1 == off || $1 == reset ]]; then
_itc_emit
elif (( $# == 3 )); then
_itc_emit $1 $2 $3
else
print -u2 'usage: tabcolor <r> <g> <b> | tabcolor off'
return 1
fi
_ITC_LAST_ROOT='<manual>'
}
autoload -Uz add-zsh-hook
add-zsh-hook chpwd _itc_update
# chpwd doesn't fire at shell start, so colour this tab now.
_itc_update