A WezTerm plugin that turns your tab bar into a notification system. Any CLI tool — AI agents, build scripts, test runners — can signal state changes via simple marker files, and WezTerm reflects them as colored tab indicators.
| State | Indicator | Tab tint | Meaning |
|---|---|---|---|
thinking |
◌ ◔ ◑ ◕ (animated) | Violet | Agent is working |
stop |
✓ | Mint | Agent finished — check results |
notify |
! | Rose | Something needs your attention |
review |
◆ | Gold | Manually flagged for review |
Inactive tabs light up when a background process writes a marker. Active tabs auto-clear stop and notify (you've seen it). thinking and review persist until explicitly removed.
When multiple panes in a tab have different states, the highest-priority one wins: notify > stop > review > thinking.
Add one line to your wezterm.lua:
local attention = wezterm.plugin.require("https://github.com/pro-vi/wezterm-attention")
attention.apply_to_config(config)By default, the plugin owns tab title formatting (dir / title + attention indicators). It also registers pane cleanup, a marker poller, and an Alt+B keybind to toggle review mode. Alt+B operates on the whole active tab: it flags the active pane, and clears the flag from every pane in the tab when any are already flagged (so split tabs can always be cleared with one press). It keys off whether review is set anywhere in the tab, independent of which indicator is currently rendered — a higher-priority stop/notify can mask the ◆.
Important: WezTerm only runs the first registered
format-tab-titlehandler. If another plugin (e.g. tabline.wez) registers one before this plugin, all attention features — indicators, colors, and auto-clear — are disabled. Make sureapply_to_configruns before any other plugin that touches tab titles, or userenderer = "manual"to integrate via the API instead.
The plugin supports three render modes:
| Mode | Who owns format-tab-title |
Per-tab colors | Use when |
|---|---|---|---|
tab (default) |
Plugin | Yes | You want it to just work |
manual |
You | Yes | You have a custom tab formatter |
-- Default: plugin owns everything
attention.apply_to_config(config)
-- Manual: you own format-tab-title, plugin provides helpers
attention.apply_to_config(config, { renderer = "manual" })
wezterm.on("format-tab-title", attention.wrap_title_formatter(function(tab, ctx)
return ctx.default_title -- your custom logic here
end))In tab mode, pass a title_formatter to control the base title without losing indicators:
attention.apply_to_config(config, {
title_formatter = function(tab, ctx)
-- ctx.default_title = "dir / pane_title"
-- ctx.attention = { indicator, type, color }
local pane = tab.active_pane
return pane.title -- just the pane title, no directory
end,
})All options are optional — defaults work out of the box:
attention.apply_to_config(config, {
-- Render mode: "tab" | "manual"
renderer = "tab",
-- Where marker files live (one file per pane ID)
dir = os.getenv("HOME") .. "/.local/state/wezterm-attention",
-- Custom base title (tab mode only; plugin adds indicators + colors around it)
title_formatter = nil, -- function(tab, ctx) -> string
-- Tab background tints per attention type
colors = {
thinking = "#1c1730", -- violet tint
stop = "#12271c", -- mint tint
notify = "#240f16", -- rose tint
review = "#1a1a0c", -- gold tint
},
-- Tab text indicators
indicators = {
thinking_frames = { "◌ ", "◔ ", "◑ ", "◕ " },
stop = "✓ ",
notify = "! ",
review = "◆ ",
},
-- Priority order (last = highest)
priority = { "thinking", "review", "stop", "notify" },
-- Auto-clear these types when switching to the tab
auto_clear = { "stop", "notify" },
-- Review toggle keybind (false to disable)
review_key = { key = "b", mods = "ALT" },
})Any process running inside WezTerm can write a marker. The contract is:
- Write a JSON file to
~/.local/state/wezterm-attention/<WEZTERM_PANE> - Contents:
{"type":"<state>"}where state isthinking,stop,notify, orreview - Optional:
{"type":"thinking","frame":0}—frame(0-3) controls the spinner position - Cleanup is automatic — markers are removed when panes close or tabs become active
The WEZTERM_PANE environment variable is injected by WezTerm into every shell it spawns. That's the pane's unique ID.
Atomic writes recommended: To avoid partial reads, write to a .tmp file then rename:
MARKER_DIR="$HOME/.local/state/wezterm-attention"
mkdir -p "$MARKER_DIR"
echo '{"type":"stop"}' > "$MARKER_DIR/$WEZTERM_PANE.tmp" && mv "$MARKER_DIR/$WEZTERM_PANE.tmp" "$MARKER_DIR/$WEZTERM_PANE"import { mkdir, writeFile, rename } from "node:fs/promises";
import { join } from "node:path";
const dir = join(process.env.HOME!, ".local", "state", "wezterm-attention");
await mkdir(dir, { recursive: true });
const file = join(dir, process.env.WEZTERM_PANE!);
await writeFile(file + ".tmp", JSON.stringify({ type: "stop" }));
await rename(file + ".tmp", file);const fs = require("fs");
const path = require("path");
const dir = path.join(process.env.HOME, ".local", "state", "wezterm-attention");
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, process.env.WEZTERM_PANE);
fs.writeFileSync(file + ".tmp", JSON.stringify({ type: "stop" }));
fs.renameSync(file + ".tmp", file);By default, the plugin registers its own update-status handler to poll marker files. If you already have one (e.g., for a git status bar), use manual polling instead:
attention.apply_to_config(config, { auto_poll = false })
-- Then in your existing update-status handler:
wezterm.on('update-status', function(window, pane)
attention.poll(window) -- reads markers, updates cache
-- ... your git status bar, battery, etc.
end)The plugin exposes functions for use in your own WezTerm Lua code:
local attention = wezterm.plugin.require("https://github.com/pro-vi/wezterm-attention")
-- Read cached attention state: returns (type, frame) or nil
local state, frame = attention.get_attention(pane:pane_id())
-- Clear a marker programmatically
attention.remove_marker(pane:pane_id())
-- Poll markers manually (for auto_poll = false)
attention.poll(window)
-- Wrap a title function with attention decoration (for renderer = "manual")
wezterm.on("format-tab-title", attention.wrap_title_formatter(function(tab, ctx)
-- ctx.default_title is "dir / title"
-- ctx.attention is { indicator, type, color }
return ctx.default_title
end))Claude Code has hooks that fire on lifecycle events. Add attention markers to each one:
| Hook event | Marker | What happens | Required? |
|---|---|---|---|
Stop |
stop |
Tab turns mint with ✓ when agent finishes | Yes — core value |
PreToolUse |
thinking |
Spinner animates while agent works | Recommended |
Notification |
notify |
Tab turns rose with ! for notifications | Optional |
PermissionRequest |
notify |
Tab turns rose when agent needs approval | Optional |
SessionEnd |
(cleanup) | Marker file removed | Recommended |
Minimum viable setup: Just the Stop hook gives you the "agent finished" indicator. Add the rest as desired.
The snippets below are fragments to paste into your hook files — not standalone scripts. Each one guards on WEZTERM_PANE so it's safe to use outside WezTerm. If you don't have existing hooks, wrap the snippet in a Claude Code hook handler (see hook docs).
Register hooks in ~/.claude/settings.json:
{
"hooks": {
"Stop": [{ "matcher": "", "hooks": ["/bin/bash ~/.claude/hooks/stop.sh"] }],
"PreToolUse": [{ "matcher": "", "hooks": ["/bin/bash ~/.claude/hooks/pre_tool_use.sh"] }],
"SessionEnd": [{ "matcher": "", "hooks": ["/bin/bash ~/.claude/hooks/session_end.sh"] }]
}
}PreToolUse — animated thinking spinner:
if (process.env.WEZTERM_PANE) {
const { mkdirSync, writeFileSync, readFileSync, renameSync } = require('fs');
const markerDir = `${process.env.HOME}/.local/state/wezterm-attention`;
const markerFile = `${markerDir}/${process.env.WEZTERM_PANE}`;
let frame = 0;
try {
const data = JSON.parse(readFileSync(markerFile, 'utf8'));
if (data.type === 'thinking') frame = ((data.frame || 0) + 1) % 4;
} catch {}
mkdirSync(markerDir, { recursive: true });
writeFileSync(markerFile + '.tmp', JSON.stringify({ type: 'thinking', frame }));
renameSync(markerFile + '.tmp', markerFile);
}Stop — agent finished:
if (process.env.WEZTERM_PANE) {
const { mkdirSync, writeFileSync, renameSync } = require('fs');
const markerDir = `${process.env.HOME}/.local/state/wezterm-attention`;
const markerFile = `${markerDir}/${process.env.WEZTERM_PANE}`;
mkdirSync(markerDir, { recursive: true });
writeFileSync(markerFile + '.tmp', JSON.stringify({ type: 'stop' }));
renameSync(markerFile + '.tmp', markerFile);
}Notification / PermissionRequest — needs attention:
if (process.env.WEZTERM_PANE) {
const { mkdirSync, writeFileSync, renameSync } = require('fs');
const markerDir = `${process.env.HOME}/.local/state/wezterm-attention`;
const markerFile = `${markerDir}/${process.env.WEZTERM_PANE}`;
mkdirSync(markerDir, { recursive: true });
writeFileSync(markerFile + '.tmp', JSON.stringify({ type: 'notify' }));
renameSync(markerFile + '.tmp', markerFile);
}SessionEnd — cleanup:
if (process.env.WEZTERM_PANE) {
const { unlinkSync } = require('fs');
try {
unlinkSync(`${process.env.HOME}/.local/state/wezterm-attention/${process.env.WEZTERM_PANE}`);
} catch {}
}Tip: Add
execSync(`wezterm cli set-window-title --pane-id ${process.env.WEZTERM_PANE} " "`)after writing a marker to force an immediate tab redraw instead of waiting for the next poll cycle.
Codex uses a single notify hook that fires when the agent finishes or needs attention. Add this to your Codex notify handler:
async function writeWezTermMarker(type: "stop" | "notify"): Promise<void> {
const paneId = process.env.WEZTERM_PANE;
const home = process.env.HOME;
if (!paneId || !home) return;
const { mkdir, writeFile } = require("node:fs/promises");
const { join } = require("node:path");
const markerDir = join(home, ".local", "state", "wezterm-attention");
await mkdir(markerDir, { recursive: true });
await writeFile(join(markerDir, paneId), JSON.stringify({ type }));
}
// In your notify handler:
// - "stop" if the agent completed work (has last-assistant-message)
// - "notify" for other notifications
const attentionType = payload["last-assistant-message"] ? "stop" : "notify";
await writeWezTermMarker(attentionType);Wire it in ~/.codex/config.toml:
[hooks]
notify = ["bun", "/path/to/your/notify.ts"]- Build systems — write
notifyon failure,stopon success - Test runners — animated
thinkingwhile running,stopornotifyon completion - Long-running scripts — any background job that wants your attention when done
- Manual triage —
Alt+Bto flag tabs for review during code review sessions
The plugin uses a poller/renderer split to avoid blocking WezTerm's GUI thread:
- Poller (
update-statusevent) — runs on WezTerm'sconfig.status_update_interval(default 1000ms). Reads marker files from disk and updates an in-memory cache. - Renderer (
format-tab-titleevent) — fires on every tab repaint (mouse hover, key press, redraws). Reads only from the cache — zero I/O, instant returns.
No background threads, no FFI, no external dependencies — just filesystem reads in Lua on a configurable interval.
Markers not showing?
- Check the directory exists:
ls ~/.local/state/wezterm-attention/(or your configureddir) - Verify
WEZTERM_PANEis set:echo $WEZTERM_PANE(should print a number inside WezTerm) - Check file contents:
cat ~/.local/state/wezterm-attention/$WEZTERM_PANE(should be valid JSON) - Ensure your hooks write to the same path as the plugin's
dirsetting status_update_intervaldefaults to 1000ms; markers update on this interval
Tab titles look wrong?
- WezTerm only runs the first registered
format-tab-titlehandler. If you have your own handler, setrenderer = "manual"and usewrap_title_formatter()or the plugin API. Two handlers cannot coexist. - Use
title_formatterto customize the base title while keeping the plugin's indicators.
Alt+B not working?
- Check for keybind conflicts. Set
review_key = falseand bind manually if needed.
LuaCATS type annotations are available via wezterm-types for IDE autocomplete and type checking. See DrKJeff16/wezterm-types#145.
MIT