SLOPSHOPPER

tmux-status

Shows Claude Code's state (working, waiting for you, done) as coloured glyphs on your tmux window tabs.

newguardprocess
v0.1.0MITupdated 2026-10-04XavierYounan/claude-code-tmux-status
A shopper browsing a rack in a slop shop
README

claude-code-tmux-status

A Claude Code mod that shows what each Claude session is doing in your tmux status bar, so you can see at a glance which windows are busy, which need you and which have finished.

tmux window tabs changing state: yellow dots while Claude works, red while it waits for you, green ticks when it finishes

GlyphStateShown
<img src="docs/working.svg" width="16" height="16" alt="yellow dot"> yellowworking on a turnalways
<img src="docs/waiting.svg" width="16" height="16" alt="red dot"> redwaiting for you: a permission prompt, a question, an MCP elicitationalways
<img src="docs/done.svg" width="16" height="16" alt="green tick"> greenfinished its turnonly on windows you are not looking at

Outside tmux the mod does nothing.

Install

1. The mod. From GitHub:

claude plugin marketplace add XavierYounan/claude-code-tmux-status
claude plugin install tmux-status@tmux-status

Or from a local clone, for one session: claude --plugin-dir /path/to/claude-code-tmux-status.

2. The tmux side. With tpm, add this after your theme's @plugin line (themes usually overwrite the window formats, so this needs to load after them), then press prefix + I:

set -g @plugin 'XavierYounan/claude-code-tmux-status'

Without tpm, clone the repo and source the snippet after your theme loads:

source-file /path/to/claude-code-tmux-status/tmux/claude-status.tmux

That prefixes the glyph to every window tab. To put it somewhere else, comment out the two if -F lines at the bottom of that file and drop #{E:@claude_status} into your own format. Colours are the @claude_icon_* options, so you can override them after sourcing.

How it works

The mod hooks Claude Code's turn, permission and tool events and runs tmux set-option -w -t $TMUX_PANE @claude_state <state>. Everything visual lives in tmux formats, so it works with any theme. The done state stays until your next prompt, but the format only shows it on inactive windows.

One state per window: if you run two Claude sessions in split panes of the same window, the last one to change wins.

Compatibility

Mods (Claude Code's function-hooks plugins) are early access, and their API can change between releases. Built and tested against Claude Code 2.1.289; if it stops working after an update, please open an issue.

Develop

claude plugin validate .
claude plugin test .
Source 1 files
hooks/register.ts 80 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3// What the window option @claude_state holds. Unset means no Claude session
4// in that window (or one that has not started a turn yet).
5export type State = 'working' | 'waiting' | 'done'
6
7const OPTION = '@claude_state'
8
9type $ = EngineInterface
10
11// The pane this Claude Code runs in; undefined outside tmux, where every hook
12// below does nothing.
13const paneOf = ($: $) => $.env.get('TMUX_PANE')
14
15const tmux = ($: $, ...argv: string[]) => $.process.run(['tmux', ...argv], { timeoutMs: 2000 })
16
17async function setState($: $, state: State | undefined) {
18  const pane = await paneOf($)
19  if (pane === undefined) return
20  await (state === undefined
21    ? tmux($, 'set-option', '-wqu', '-t', pane, OPTION)
22    : tmux($, 'set-option', '-wq', '-t', pane, OPTION, state))
23}
24
25export const register: Register = on => {
26  on('session.start', async ($, e, next) => {
27    const started = await next(e)
28    await setState($, undefined)
29    return started
30  })
31
32  on('session.end', async ($, e, next) => {
33    await setState($, undefined)
34    return next(e)
35  })
36
37  // Main-loop turns only: a subagent's run raises no turn.start, and its
38  // turn.complete carries an agentId.
39  on('turn.start', async ($, e, next) => {
40    await setState($, 'working')
41    return next(e)
42  })
43
44  on('turn.complete', async ($, e, next) => {
45    const done = await next(e)
46    if (e.agentId === undefined) await setState($, 'done')
47    return done
48  })
49
50  // Blocked on the person: a permission dialog, an MCP elicitation, or a
51  // question the model asked. Answering it resumes the turn.
52  on('classic.PermissionRequest', async ($, e, next) => {
53    await setState($, 'waiting')
54    return next(e)
55  })
56  on('classic.Elicitation', async ($, e, next) => {
57    await setState($, 'waiting')
58    return next(e)
59  })
60  on('classic.PermissionDenied', async ($, e, next) => {
61    await setState($, 'working')
62    return next(e)
63  })
64  on('classic.PostToolUse', async ($, e, next) => {
65    await setState($, 'working')
66    return next(e)
67  })
68  on('classic.ElicitationResult', async ($, e, next) => {
69    await setState($, 'working')
70    return next(e)
71  })
72
73  on('tool.call', { tool: 'AskUserQuestion' }, async ($, e, next) => {
74    await setState($, 'waiting')
75    const answered = await next(e)
76    await setState($, 'working')
77    return answered
78  })
79}
80