Which Claude CLI Prompt Finished? Put a ✅ on the tmux Tab
Claude Code hooks plus a per-window tmux option turn every tab into a live status light: working, waiting on background shells, or ready to read.
I usually have four or five Claude Code sessions running at once, one per tmux window. One is a long refactor I'm not waiting on, one is a quick question I want answered now, one is research I'll get to in ten minutes. Different shapes, different durations, finishing in an order I can't predict.
The bottleneck stopped being Claude a while ago. It's me noticing which window finished. Nothing in the tab bar separates a session sitting on an answer it produced four minutes ago from one that's still thinking, so I'd cycle through all of them every couple of minutes to find out. Multiply that by a day and it's a real tax on attention.
What I wanted was boring: a mark on the tab of whichever window finished answering, which goes away once I've looked at it. It ended up as three marks, because "finished" turned out to have two meanings.
| Tab shows | Meaning |
|---|---|
| (nothing) | Idle. Nothing running, and anything it produced has been seen |
🔄 |
Prompt in flight, Claude is working |
⏳ |
Answer finished, but background shells it started are still running |
✅ |
Answer finished, nothing running, ready to read |
❐ 0 1 Billing ✅ 2 Review 🔄 3 Flightplan 4 Max ⏳ 5 Wiki
↑ ready to read ↑ working ↑ shells still goingWorth stating what I'm working with, because the wiring hangs off it: macOS, tmux from Homebrew, and the gpakosz Oh my tmux! config with every personal override confined to ~/.tmux.conf.local. One tmux session, one window per thing I'm working on, prefix remapped to C-a. It's the same tmux setup I run on my remote dev containers, so this works there too. On a different theme the idea carries over fine, but the specific line you edit won't be the one below.
Three approaches that don't work
I tried the obvious things first. Documenting the dead ends because they all look reasonable on paper.
monitor-activity. tmux can flag a window when its pane produces output. This is exactly backwards for Claude Code. The spinner is constant output, so a window that's busy thinking looks identical to one that just finished. Every window is permanently flagged, which is the same as no window being flagged.
Renaming the window. Prepending a ✅ to the window name works right up until you have manual window names you care about (I do), or two sessions finish near each other and race on the rename. Names are shared mutable state and there's no clean way to restore the original.
The Notification hook with idle_prompt. This one is close. It fires when Claude has been waiting on you for a while, which is nearly what I want, except it fires after an idle delay. The badge shows up long after the answer did. If the point is to notice quickly, a built-in lag defeats it.
Hooks are the right primitive
Claude Code has a hooks system in ~/.claude/settings.json. Two events matter here:
Stopfires when Claude finishes a response.UserPromptSubmitfires when you send a new prompt.
Those are the exact edges of "there is an unread answer in this window." Set on Stop, clear on UserPromptSubmit. (Visiting the tab clears it too, but that part is pure tmux and comes later.)
The part that makes this work at all: hook commands inherit the session's environment, which means $TMUX_PANE is set and identifies precisely which pane, and therefore which window, the session lives in. No guessing, no PID walking, no scanning for the frontmost anything. Each session addresses its own tab.
For the badge state I use a per-window user option, @claude_status. tmux lets you hang arbitrary @-prefixed options off a window, they're scoped per window, and the theme can read them. Nothing else on the system cares about them.
The script
Here's the first version, which only knows about ✅. It grows a third state later in the post, but everything structural is already here. ~/.claude/hooks/tmux-claude-status.sh:
#!/bin/sh
# Badge the tmux window when Claude Code finishes a turn; clear it on new prompt.
[ -n "$TMUX" ] && [ -n "$TMUX_PANE" ] || exit 0
TMUX_BIN="/opt/homebrew/bin/tmux"
[ -x "$TMUX_BIN" ] || TMUX_BIN="tmux"
case "$1" in
stop)
# Skip the badge when the user is already looking at this window.
if [ "$("$TMUX_BIN" display-message -p -t "$TMUX_PANE" '#{window_active}' 2>/dev/null)" = "1" ]; then
exit 0
fi
"$TMUX_BIN" set-option -w -t "$TMUX_PANE" @claude_status done 2>/dev/null
;;
prompt)
"$TMUX_BIN" set-option -w -t "$TMUX_PANE" -u @claude_status 2>/dev/null
;;
esac
# Redraw the status line immediately (it otherwise refreshes every 10s).
for c in $("$TMUX_BIN" list-clients -F '#{client_tty}' 2>/dev/null); do
"$TMUX_BIN" refresh-client -S -t "$c" 2>/dev/null
done
exit 0Then chmod +x it.
Three details in there earn their keep:
The first line bails cleanly when there's no tmux. Hooks run for every session, including ones started in a plain terminal or over SSH. A hook that errors is noise in a place you don't want noise, so it exits 0 and does nothing.
The window_active check is what keeps the badge meaningful. If you're already watching the window when Claude finishes, you saw it happen and don't need to be told. Without this you get a badge on the window you're literally looking at, which trains you to ignore badges.
The refresh-client -S loop is the difference between "instant" and "sometimes." The tmux status line redraws on a 10 second tick by default. Setting the option doesn't force a redraw, so without this the badge shows up whenever the next tick happens to land. Pushing the redraw to every attached client makes it immediate.
Wiring it up
In ~/.claude/settings.json, alongside whatever's already there:
"hooks": {
"Stop": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh stop" }] }
],
"UserPromptSubmit": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh prompt" }] }
],
"SessionEnd": [
{ "hooks": [{ "type": "command", "command": "/Users/you/.claude/hooks/tmux-claude-status.sh end" }] }
]
}SessionEnd isn't needed yet at this stage, but it becomes load-bearing once the badge grows states that survive window visits. There's a section on it below.
And in the window tab format. I use the gpakosz tmux config, so this is one line in ~/.tmux.conf.local:
-tmux_conf_theme_window_status_format='#I #W'
+tmux_conf_theme_window_status_format='#I #W#{?@claude_status, ✅ ,}'#{?option,then,else} is a tmux conditional. When @claude_status is set, render the badge; otherwise render nothing. (This grows into a nested three-way conditional once there are three states. Same idea, more parentheses.)
The trailing space after the ✅ is load-bearing. The emoji is double-width, and tmux and iTerm2 disagree by one cell about how much room it takes. Without the extra space, the powerline separator on the tab overlaps the glyph and you get a clipped half-check. I lost more time to this than to the actual hook logic. If it still clips in your terminal, a single-width themed check avoids the problem:
#{?@claude_status, #[fg=#27ba09]✔#[none],}Reload with tmux source-file ~/.tmux.conf, and you can test the rendering without waiting on a real prompt:
tmux set-option -w -t :1 @claude_status done # badge appears on window 1
tmux set-option -w -t :1 -u @claude_status # and goneWhen should the badge go away?
The wiring above is mechanical. This is the first question with an actual answer to get wrong, and I got it wrong.
The hooks above give you one lifecycle: the badge appears on Stop and survives until you submit the next prompt in that window. Visiting the tab to read the answer doesn't clear it. I built that deliberately, reasoning that the tab bar should be a list of things I still owe a response to.
In practice it's wrong. Most of what those sessions produce is something I read and don't reply to. Research I asked for, a refactor I skim and accept, an answer that just tells me a thing. Under persist-until-prompt those windows keep their ✅ indefinitely, because I'm never going to type another prompt in them. Within a day the tab bar was mostly stale checkmarks, which is exactly the failure mode the window_active check was there to prevent.
Clearing on visit is better because the badge means "you haven't looked at this yet," and looking at it is the thing that makes it false. One line of tmux:
set-hook -g after-select-window 'set-option -w -u @claude_status' # brokenThat line is wrong, and it's wrong in the most annoying way available: it looks right, it installs without complaint, and it passes a test.
The hook that never fired
The symptom was that the ✅ appeared exactly when it should and then stayed forever. Clicking into the window did nothing. Only submitting a new prompt cleared it, which is the behavior I had just replaced.
after-select-window is a command hook. It fires when the literal select-window command runs. The problem is that almost nothing you actually do runs select-window. I instrumented both hooks and drove each switching method to see what really happens:
| How you switch | Command tmux runs | after-select-window |
session-window-changed |
|---|---|---|---|
| Click the tab in the status bar | switch-client -t = |
no | yes |
| Scroll wheel over the status bar | next-window / previous-window |
yes | yes |
prefix C-h / C-l |
next-window / previous-window |
yes | yes |
prefix + number |
select-window |
yes | yes |
Clicking the tab is the one path that misses, and clicking the tab is how I switch windows almost every time. So the hook was installed, correct-looking, and silently dead in the exact case that mattered.
The part worth internalizing is why my testing didn't catch it. I verified the hook by running tmux select-window -t :1 from a script and watching the badge clear. It cleared. That's a real test of the hook, and it's the one path real usage never takes. Testing a UI behavior by invoking the command I assumed the UI runs just confirms my assumption back to me. The fix was to stop assuming and go look at list-keys -T root, which says plainly that MouseDown1Status is bound to switch-client -t =.
The fix itself is a one-word change. session-window-changed is an event hook: it fires whenever the session's current window changes, no matter which command caused it. It covers all four rows.
set-hook -g session-window-changed 'set-option -w -u @claude_status'Keeping both
I still wanted the old behavior available, because "answers I owe a reply to" is genuinely the right view when I'm driving a batch of sessions rather than farming them out. So the hook reads an option instead of acting unconditionally, and a keybinding flips it:
set -g @claude_badge_clear_on_visit 1
set-hook -g session-window-changed 'if -F "#{&&:#{@claude_badge_clear_on_visit},#{==:#{@claude_status},done}}" "set-option -w -u @claude_status"'
bind B if -F '#{@claude_badge_clear_on_visit}' \
'set -g @claude_badge_clear_on_visit 0 ; display-message "Claude badge: ✅ persists until next prompt"' \
'set -g @claude_badge_clear_on_visit 1 ; display-message "Claude badge: ✅ clears when you visit the tab"'Reading the option inside the hook is what makes this live. A bare set-hook can't be switched off without re-sourcing the config, but a hook that consults an option every time it fires can. if -F evaluates a format and treats empty or 0 as false, which is why the disabled state can be a plain 0 instead of unsetting the option.
The #{&&:...} is doing the other half of the work, and it matters more once there are three states. Visiting a tab should clear done and nothing else, because running and ⏳ describe work that's still happening. Seeing them doesn't make them untrue. So the hook fires on every window change but only acts when the toggle is on and the state is exactly done.
The shell script doesn't change for any of this. UserPromptSubmit still resets the state in both modes, which is what makes the persist lifecycle work when you flip to it.
"Finished" means two different things
Once the badge was actually working I started catching a lie in it.
Claude Code can run shells in the background: a long test suite, a build, a dev server. When it does that and then ends its turn, Stop fires and the window gets a ✅. But the work isn't done. Something is still running in there, and the tab is telling me the session is ready to read. I'd click in expecting an answer and find a build at 40%.
So @claude_status stopped being a flag and became a small state machine with three values. running while a prompt is in flight, shells when the turn ended but background shells are still alive, done when there's genuinely nothing left.
tmux_conf_theme_window_status_format='#I #W#{?#{==:#{@claude_status},running}, 🔄 ,#{?#{==:#{@claude_status},shells}, ⏳ ,#{?#{==:#{@claude_status},done}, ✅ ,}}}'That's the same #{?...} conditional as before, nested three deep, with #{==:x,y} for the comparisons. Every emoji keeps its trailing space for the same clipping reason.
Finding the background shells
There's no API for this, so it's a process-tree question. At Stop time, walk from the pane down to the claude process and count its direct shell children:
bg_shell_count() {
pane_pid=$("$TMUX_BIN" display-message -p -t "$TMUX_PANE" '#{pane_pid}' 2>/dev/null)
[ -n "$pane_pid" ] || { echo 0; return; }
case "$(ps -o comm= -p "$pane_pid" 2>/dev/null)" in
*claude) claude_pid=$pane_pid ;;
*) claude_pid=$(ps -eo pid,ppid,comm | awk -v pp="$pane_pid" '$2==pp && $3 ~ /claude$/ {print $1; exit}') ;;
esac
[ -n "$claude_pid" ] || { echo 0; return; }
anc=" $$ "; p=$$; i=0
while [ "$i" -lt 10 ]; do
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' '); [ -n "$p" ] || break
anc="$anc$p "; [ "$p" = "$claude_pid" ] && break; i=$((i+1))
done
n=0
for c in $(ps -eo pid,ppid,comm | awk -v cp="$claude_pid" '$2==cp && $3 ~ /\/(zsh|bash|sh)$|^(zsh|bash|sh)$/ {print $1}'); do
case "$anc" in *" $c "*) ;; *) n=$((n+1)) ;; esac
done
echo "$n"
}Four things in there are less arbitrary than they look.
Filtering on shell names is what separates background work from infrastructure. A Claude Code session also has MCP servers hanging off it as node or python children, plus a caffeinate if something asked to keep the machine awake. Those are alive constantly and would pin every window to ⏳ forever. Background shells are zsh, so matching shell names only is the whole filter.
The ancestry walk exists because the hook is itself a shell child of claude. Without excluding it, the script counts itself, every window sits at ⏳ permanently, and the state machine never reaches ✅. It walks its own parent chain up to the claude process and skips anything it finds on the way.
ps, not pgrep. pgrep -P is the obvious tool for "children of this pid" and it silently returned nothing from inside the hook's sandboxed execution context. Not an error, just an empty result, which reads exactly like "no background shells." Parsing ps -eo pid,ppid,comm works reliably.
The ⏳ → ✅ transition needs no polling, which surprised me. When a tracked background shell exits, Claude Code wakes the session for a completion turn. That fires Stop again, the count comes back zero, and the badge resolves. I watched it live: 🔄 on submit, ⏳ seven seconds later when the turn ended with a sleep still running, then ✅ the moment the sleep exited. No timer, no watcher process.
The one gap: a raw disowned cmd & isn't tracked by Claude Code, so nothing wakes the session when it finishes, and its ⏳ waits for whatever you do next in that window.
The state that never ends
Making running and shells survive window visits is correct, since seeing that work is happening doesn't make it stop happening. But it quietly creates a state with no exit.
If a session ends while its badge is 🔄 or ⏳, that badge is permanent. Visiting won't clear it, because those states are defined not to. Stop won't clear it, because there's no session left to end a turn. The tab sits there claiming work is in flight for a process that no longer exists, and the only cure is submitting a prompt in that window or unsetting the option by hand. It's the same class of bug as the stale ✅ from earlier in the post: a badge that lies makes you stop trusting all of them.
The missing edge is SessionEnd, which fires when a session goes away:
end|unset)
unset_status
;;Wired to the SessionEnd event in the settings block above, that's the whole fix.
Testing it is where I embarrassed myself. Twice I set up a session with a live background shell, sent /exit, watched the ⏳ stay put, saw no end in my instrumented log, and concluded the hook didn't fire on that path. What actually happens is that Claude Code asks first:
The following will stop when you exit:
shell · sleep 45
❯ 1. Exit anyway 2. Move to background and exit 3. StayThe session hadn't ended, so SessionEnd was right not to fire. My test had sent /exit and never answered the question. Answer it and the hook fires and the badge clears, exactly as intended.
That's twice in one project that I got a confident wrong answer from a test that never reached the code path it claimed to exercise. The first time I drove select-window because I assumed that's what clicking a tab does. This time I sent /exit and assumed that's what exiting means. Both tests ran clean and told me nothing.
One hole remains, and it's the option in the middle of that prompt. Choose "Move to background and exit" and the shells keep running with no session left to report on them, so that window keeps its ⏳ until you use it again. I've left it, because at that point the badge is arguably telling the truth: something really is still running in there.
Two things that will confuse you
Hooks are snapshotted when a session starts. Add all of this, watch nothing happen in your existing sessions, and start debugging the script. The script is fine. Long-running sessions captured their hook config at launch and won't pick up changes. Restart them, or run /hooks in each and approve. New sessions get it automatically.
prefix + r suddenly fails with returned 127. Unrelated to any of this, but it will bite you eventually and it looks like your new config broke something. The gpakosz framework stores a TMUX_PROGRAM environment variable pointing at a versioned Homebrew Cellar path, like .../Cellar/tmux/3.6b/bin/tmux. Upgrade tmux and that path stops existing, so every config reload fails silently until you notice. Fix:
tmux set-environment -g TMUX_PROGRAM /opt/homebrew/bin/tmux
tmux source-file ~/.tmux.confThis breaks on every Homebrew tmux upgrade, so I added it as a check to the doctor.sh in my machine setup repo rather than rediscovering it twice a year.
Three adjacent fixes
While I was in here I chased down some separate annoyances that had been going on for months.
The dinging. The gpakosz config ships monitor-activity on with visual-activity off, which means tmux turns "activity in another window" into an audible bell. That's a reasonable default until every window contains a Claude spinner animating constantly, at which point it's a machine that dings forever, worst right after switching tabs because the alert re-arms for the window you just left. I'd half-assumed this was an iTerm setting and never chased it. The badges do everything activity monitoring was doing here, so it can go entirely:
set -g monitor-activity off
set -g bell-action noneThe frozen window. Occasionally one iTerm window's tmux would look completely dead. No response to tab clicks, no response to the prefix. Opening a new iTerm window and attaching to the same session worked fine, which made it look like a client-specific tmux bug.
It isn't. The frozen client was still sending input the whole time. tmux was logging its clicks. Only its output was frozen, which is a very different problem and has two causes worth immunizing against:
Ctrl-S. XON/XOFF flow control, which freezes terminal output until you press Ctrl-Q. It's one key away from Ctrl-A if your prefix is remapped there, and nothing on screen tells you what happened. Turn it off in ~/.zshrc, before the line that sources oh-my-zsh:
# Disable XON/XOFF flow control: an accidental Ctrl-S freezes the terminal's
# output (looks like a dead tmux client; Ctrl-Q unfreezes).
[[ -t 0 ]] && stty -ixonOrdering matters. If oh-my-zsh is configured to autostart tmux, it execs straight into it and never reaches anything below that line.
prefix + Ctrl-z, which is suspend-client. One key off the C-a C-a pane-cycling chord I use constantly. When your shell exec'd into tmux there's no visible job control to resume from, so it presents as a dead terminal:
unbind C-zIf a window still freezes on you, try Ctrl-Q in it first. Otherwise, from a healthy window, tmux list-clients shows the stuck tty and tmux detach-client -t /dev/ttysNNN frees it.
Every fix in this section is one line, and between them they were costing me a restarted terminal every couple of weeks and an ambient stream of dings I'd stopped noticing I was annoyed by. None of them had anything to do with badges. I only found them because building the badges made me read config I'd been carrying unexamined for years.
Small thing overall. It took the polling loop out of my day.