Skip to content

Instantly share code, notes, and snippets.

@krisleech
Created August 18, 2026 14:59
Show Gist options
  • Select an option

  • Save krisleech/da33c15081e986b94def6701e424fb00 to your computer and use it in GitHub Desktop.

Select an option

Save krisleech/da33c15081e986b94def6701e424fb00 to your computer and use it in GitHub Desktop.
iterm2 automatically coloured tabs

iTerm2 tab colours by directory

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

How it works

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:

  1. Nearest ancestor containing .git (tested with -e, not -d, so git worktrees and submodules — where .git is a file — match too). The walk stops at $HOME so a stray ~/.git can't swallow everything.
  2. Otherwise, the first directory below a workspace root (_ITC_WORKSPACES, default ~/dev), so non-repo directories like ~/dev/play still get their own colour instead of all of ~/dev sharing one.
  3. Otherwise, the first directory under $HOME.
  4. 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.

Current assignments

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

Design notes

  • 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-os and play on 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_root and _itc_hash return via globals rather than stdout, and the hash is computed in zsh arithmetic rather than shelling out. Measured at 0.062 ms per cd.
  • Palette size was not tuned to fit the current project names. That would look clever and silently break the moment a new repo appeared.

Customising

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          # clear

The next cd across a project boundary re-asserts automatic control.

Limitations

  • Only updates on cd and at shell start. A tab parked in vim or ssh for an hour keeps whatever colour it had.
  • ssh leaves the colour set. The remote shell doesn't emit anything, so the tab keeps the local project's colour for the session. Fixable with a preexec hook 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_OVERRIDES table above is the one-line fix.

Reproducing on another machine

Requirements

  • 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-hook is stock zsh.
  • oh-my-zsh is optional — it only provides the auto-sourcing. The script is self-contained and runs fine under zsh -f.

Steps

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:211 globs $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 clobbers chpwd_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.

Keeping colours consistent across machines

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
# 9f11e053413d59f0f15146f2d2038a477194f44c1a541d4c277c0d6f8f98aef9

Tweak the palette on one machine and re-copy, rather than editing both.

Caveat: this file is not version controlled

~/.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.


Verified behaviour

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.


Full script

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment