Skip to content

pro-vi/wezterm-attention

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 

Repository files navigation

wezterm-attention

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.

What it looks like

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.

Install

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.

Important: WezTerm only runs the first registered format-tab-title handler. If another plugin (e.g. tabline.wez) registers one before this plugin, all attention features — indicators, colors, and auto-clear — are disabled. Make sure apply_to_config runs before any other plugin that touches tab titles, or use renderer = "manual" to integrate via the API instead.

Render modes

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))

Custom tab titles

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,
})

Configure

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" },

})

The protocol

Any process running inside WezTerm can write a marker. The contract is:

  1. Write a JSON file to ~/.local/state/wezterm-attention/<WEZTERM_PANE>
  2. Contents: {"type":"<state>"} where state is thinking, stop, notify, or review
  3. Optional: {"type":"thinking","frame":0}frame (0-3) controls the spinner position
  4. 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:

Shell (one-liner)

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"

TypeScript / Bun

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);

Node.js

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);

Existing update-status handler?

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)

Public API

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 hooks

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 hooks

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"]

Other use cases

  • Build systems — write notify on failure, stop on success
  • Test runners — animated thinking while running, stop or notify on completion
  • Long-running scripts — any background job that wants your attention when done
  • Manual triageAlt+B to flag tabs for review during code review sessions

How it works

The plugin uses a poller/renderer split to avoid blocking WezTerm's GUI thread:

  1. Poller (update-status event) — runs on WezTerm's config.status_update_interval (default 1000ms). Reads marker files from disk and updates an in-memory cache.
  2. Renderer (format-tab-title event) — 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.

Troubleshooting

Markers not showing?

  • Check the directory exists: ls ~/.local/state/wezterm-attention/ (or your configured dir)
  • Verify WEZTERM_PANE is 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 dir setting
  • status_update_interval defaults to 1000ms; markers update on this interval

Tab titles look wrong?

  • WezTerm only runs the first registered format-tab-title handler. If you have your own handler, set renderer = "manual" and use wrap_title_formatter() or the plugin API. Two handlers cannot coexist.
  • Use title_formatter to customize the base title while keeping the plugin's indicators.

Alt+B not working?

  • Check for keybind conflicts. Set review_key = false and bind manually if needed.

Type annotations

LuaCATS type annotations are available via wezterm-types for IDE autocomplete and type checking. See DrKJeff16/wezterm-types#145.

License

MIT

About

WezTerm plugin: file-based attention markers turn tabs into a notification surface for AI agents, builds, and scripts

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages