Show Claude Code session status in the GNOME panel

Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.

Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.

Two distinctions carry the design:

  * blocked (permission prompt) is kept apart from waiting (turn done).
    Merged, a finished task looks as urgent as a stuck one, which is
    exactly the judgement the indicator exists to make.

  * the panel names the oldest session in the top state, not the latest.
    The session you forget is the one that has been waiting longest.

PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.

Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.

Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.

lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.
This commit is contained in:
av
2026-08-09 18:11:27 +03:00
commit 7fc7f63842
16 changed files with 1898 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
schemas/gschemas.compiled
__pycache__/
+123
View File
@@ -0,0 +1,123 @@
# Индикатор статуса Claude Code в панели GNOME
Заметка для быстрого старта. Написана до кода — здесь замысел и проверенные
факты об окружении, а не описание существующего.
## Задача
Знать, **когда переключиться** на терминал с Claude Code и **на какой именно**,
не глядя в сами терминалы. Состояний три: работает / ждёт меня / тихо.
Сессий одновременно несколько (разные проекты, разные терминалы) — это основной
режим, а не краевой случай.
## Решения, принятые заранее
- **Уведомлений не будет.** `notify-send` отвергнут сознательно, хотя он и был бы
дешевле. Следствие: панель — единственный канал, значит расширение делается
сразу, «сначала уведомления, потом может быть индикатор» отпадает.
- **Панель называет проект, а не только состояние.** «Кто-то ждёт» оставляет
гадать, в каком из четырёх терминалов; знать «когда» без «куда» бесполезно.
- **Сабагенты внутри сессии не показываются.** Батч из восьми задач — это одна
строка «работает 40 мин», и это правильная строка: пятый воркер из восьми ни о
чём не просит. Хук `SubagentStop` для этого существует, но вешать на него
статус — шум. Если понадобится прогресс внутри батча (сделано N из 8), брать
его надо из учёта задач, а не из хуков.
## Окружение (проверено 2026-08-07)
- GNOME Shell **46.0**, сессия **Wayland**, `XDG_CURRENT_DESKTOP=ubuntu:GNOME`.
- `~/.claude/settings.json`: ключа `hooks` нет вовсе (`{}`) — место чистое,
ничего не сломаем. `statusLine` занят: `bash ~/.claude/statusline-command.sh`.
## Шаблон — своё же расширение
`~/projects/private/sing-box-gnome-extension`, оно же
`sing-box-status@git.vakhrushev.me`. Установлено **симлинком** из репозитория в
`~/.local/share/gnome-shell/extensions/<uuid>` — так же ставим и это.
Скелет копируется целиком:
```
extension.js 16 строк: enable() → Main.panel.addToStatusArea(uuid, indicator)
lib/indicator.js панель и меню
lib/format.js форматирование
prefs.js настройки
schemas/ gschema.xml + gschemas.compiled
metadata.json "shell-version": ["45","46","47","48"]
stylesheet.css
```
Меняется только источник данных: вместо опроса Clash API по HTTP —
**`Gio.FileMonitor`** на каталоге состояния. Это push, поллинг не нужен, выходит
проще оригинала.
Предполагаемый uuid: `claude-code-status@git.vakhrushev.me`.
## Источник данных — хуки Claude Code
Пишутся в глобальный `~/.claude/settings.json`, чтобы работало во всех проектах.
| Хук | Состояние |
|---|---|
| `SessionStart` | сессия появилась |
| `UserPromptSubmit` | работает |
| `Notification` | ждёт меня — разрешение или простой на вводе |
| `Stop` | ход закончен, ждёт ввода |
| `SessionEnd` | сессия исчезла |
`Notification` и `Stop` **разделять в панели**: первое горит (заблокирована на
разрешении), второе просто ждёт (задача сделана). Слитые в одно, законченная
задача выглядит так же срочно, как заблокированная.
### Файл состояния
Один файл на сессию, иначе параллельные сессии затирают друг друга:
```
~/.local/state/claude-code-status/<session_id>.json
```
Поля: состояние, `cwd`, метка времени последней смены, `$PPID` для проверки
живости. `SessionEnd` файл удаляет.
**Проверить на первом же прогоне** (по памяти, не подтверждено): хук получает на
stdin JSON с `session_id`, `cwd`, `transcript_path`, `hook_event_name`. Имена
полей сверить с реальным вводом, а не доверять этой строке.
Названия событий `SubagentStop`, `SessionEnd`, `UserPromptSubmit` подтверждены —
встречаются в конфиге плагина wakatime (`~/.claude/plugins/cache/wakatime/`),
там же можно подсмотреть рабочий пример hooks.json.
## Правила отображения
Приоритет агрегации: **хоть одна ждёт → «ждёт»**, иначе **хоть одна работает →
«работает»**, иначе тихо.
- Показывается **дольше всех ждущая** сессия, не последняя: последняя и так
свежа в голове, забывается именно давняя.
- Несколько ждущих — счётчиком: `✋ dev-skills +2`.
- Для работающей полезно «работает 6 мин» — по этому решаешь, ждать или уходить.
- В меню — список сессий с **полным путём**: два worktree одного репозитория по
basename не различаются.
## Известные шероховатости
- **Протухание.** Убитый терминал не пришлёт `SessionEnd`, файл останется.
Лечится меткой времени плюс проверкой живости `$PPID` — не идеально, но
практично; индикатор гасит протухшие.
- **Клик в меню не переключит фокус** на нужный терминал: на Wayland расширению
это просто так не даётся. Меню информационное.
- **`Stop` не отличает «закончил» от «упал»** — оба выглядят как «ждёт ввода».
Для задачи «пора переключиться» разницы нет, но знать стоит.
## Порядок работ
1. Хуки и формат файла состояния — в `~/.claude/settings.json`.
2. **Проверить на живых сессиях** через `watch cat`, что состояния переключаются
правильно и поля stdin те, что ожидались. Здесь же выяснится, когда реально
срабатывает `Notification`.
3. Индикатор поверх заведомо верных данных.
Порядок именно такой: отлаживать GJS и раскладку состояний одновременно — это
две неизвестные в одном уравнении.
+130
View File
@@ -0,0 +1,130 @@
# Claude Code Status
GNOME Shell indicator that answers one question at a glance: **is any Claude
Code session waiting for me, and which one?**
With several sessions open in different projects, the cost is not knowing what
each one is doing — it is noticing that one of them stopped an hour ago. The
panel names the session, not just the state.
## States
| Panel | State | Meaning |
|---|---|---|
| disc inside a ring | `blocked` | stuck on a permission prompt — it cannot proceed without you |
| filled disc | `waiting` | turn finished, waiting for your input |
| ring | `busy` | working |
| dim dashed ring | `idle` | started, nothing asked yet, or nothing running |
`blocked` and `waiting` are kept apart on purpose. Merged into one "needs you",
a finished task looks as urgent as a blocked one, and the distinction is exactly
what decides whether to switch now or after the current thought.
The panel shows the **highest-priority** session and, when others share that
state, a `+N` count. Within a state the **oldest** one wins: the session you
have forgotten about is the one that has been waiting longest, never the latest.
## Install
```sh
git clone <this repo> ~/projects/private/claude-code-gnome-extension
cd ~/projects/private/claude-code-gnome-extension
# 1. hooks: teach Claude Code to publish session state
./hooks/install.py
# 2. extension: symlink, compile the schema, enable
ln -s "$PWD" ~/.local/share/gnome-shell/extensions/claude-code-status@git.vakhrushev.me
glib-compile-schemas schemas/
gnome-extensions enable claude-code-status@git.vakhrushev.me
```
Running sessions pick the hooks up without restarting — Claude Code re-reads
`settings.json` as it changes.
On Wayland, changing extension *code* still needs a logout; enabling it for the
first time does not.
`./hooks/install.py --uninstall` removes the hook registration and leaves the
rest of `~/.claude/settings.json` untouched. A `.bak` copy is written on every
run.
## How it works
Claude Code hooks write one small JSON file per session to
`~/.local/state/claude-code-status/<session_id>.json`; the extension watches
that directory with `Gio.FileMonitor`. Nothing polls, and there is no daemon —
a state change reaches the panel as soon as the hook returns.
| Hook | Effect |
|---|---|
| `SessionStart` | session appears as `idle`, and dead sessions are swept |
| `UserPromptSubmit` | `busy` |
| `PostToolUse` | `busy` |
| `PreCompact` | `busy` |
| `Notification` | `blocked` or `waiting`, depending on `notification_type` |
| `Stop` | `waiting` |
| `SessionEnd` | file removed |
`PostToolUse` is not redundant: it is the only event that fires after a
permission is granted, so without it a session stays `blocked` in the panel for
the rest of the turn. It writes only when the state actually changes, so the
usual case costs a process spawn and no I/O.
`Stop` and `SessionEnd` are registered synchronously. Both fire as the process
is about to go quiet, and an async hook racing that exit gets killed before it
writes — `claude -p` was observed leaving a session pinned at `busy` forever.
Hooks for one session run concurrently, so the whole read-decide-write runs
under an `flock` on `<session_id>.json.lock`, and an event older than the stored
one is refused. Both are needed: the timestamp guard alone still lets a hook
that read the old state before a `Stop` write its stale decision afterwards.
Sub-agents are not shown. A batch of eight tasks is one line, "working 40 min",
which is the right line: the fifth of eight workers is not asking for anything.
## zellij
When sessions run in zellij tabs, the menu shows the **tab name** and a click
switches to it. The lookup goes through `zellij action dump-layout`, matching a
session's working directory against pane directories — the layout dump carries
no pane ids, so `ZELLIJ_PANE_ID` cannot be used for it. Two sessions in one tab
are therefore indistinguishable, and a session whose `cwd` has moved since the
pane opened will not match. Rows that cannot be resolved stay inert rather than
pretending a click does something.
Turn it off in Settings if you do not use zellij; it costs one process every
couple of minutes.
## Known rough edges
- **Stale sessions.** A killed terminal never sends `SessionEnd`. Liveness is
rechecked every 20 s against `/proc/<pid>` and the file is deleted, so a
killed session disappears within that window rather than lingering. When the
hook cannot identify the claude process at all it records pid 0 — "unknown",
never confused with "dead" — and those entries expire on age instead, after
36 h.
- **`Stop` cannot tell "finished" from "gave up".** Both read as "waiting for
input", which is the same decision for you either way.
- **Focus follows the zellij tab, not the window.** gnome-terminal runs every
window under one shared server process, so a window cannot be matched by pid.
A window is raised only when its title names the zellij session; when no
window matches, the tab still switches and focus is left alone, because
raising an arbitrary terminal is worse than raising none.
## Testing
```sh
gjs -m tests/test-sessions.js # state reading, ordering, liveness, monitoring
tests/test-hook.sh # event -> state machine, locking, teardown
```
`lib/sessions.js` imports nothing from `resource:///org/gnome/shell`, which is
what keeps it runnable outside the compositor.
To watch the raw hook events, create the marker file and every event will be
appended to it:
```sh
touch ~/.local/state/claude-code-status/debug
```
+16
View File
@@ -0,0 +1,16 @@
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import { ClaudeStatusIndicator } from './lib/indicator.js';
export default class ClaudeCodeStatusExtension extends Extension {
enable() {
this._indicator = new ClaudeStatusIndicator(this);
Main.panel.addToStatusArea(this.uuid, this._indicator);
}
disable() {
this._indicator?.destroy();
this._indicator = null;
}
}
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env python3
"""Claude Code hook -> per-session state file for the GNOME panel indicator.
Reads one hook event as JSON on stdin and maps it to a session state, written
to $XDG_STATE_HOME/claude-code-status/<session_id>.json. The GNOME extension
watches that directory; no polling, no daemon, no socket.
Design notes that are easy to get wrong:
* Writes are skipped when the state does not change. PostToolUse fires on every
tool call, and its only job here is to clear "blocked" once a permission has
been granted -- letting it rewrite the file each time would make the directory
monitor fire hundreds of times per turn for no new information.
* Hooks are registered async, so two events can race (the last PostToolUse of a
turn against that turn's Stop). The whole read-decide-write runs under a file
lock and an event older than the stored one is refused; without both, a late
"busy" buries "waiting" and the panel claims a session is working while it
actually waits for input.
* No stdlib import beyond what is needed: this runs once per tool call.
"""
import fcntl
import json
import os
import sys
import time
STATE_DIR = os.path.join(
os.environ.get("XDG_STATE_HOME") or os.path.expanduser("~/.local/state"),
"claude-code-status",
)
# Notification covers several unrelated things; only some mean "the session
# stopped and is asking me something". auth_success / elicitation_complete /
# elicitation_response are progress chatter and must not touch the state.
NOTIFICATION_STATES = {
"permission_prompt": "blocked",
"agent_needs_input": "blocked",
"elicitation_dialog": "blocked",
"idle_prompt": "waiting",
"agent_completed": "waiting",
}
EVENT_STATES = {
"SessionStart": "idle",
"UserPromptSubmit": "busy",
"PreCompact": "busy",
"PostToolUse": "busy",
"Stop": "waiting",
}
def debug_log(event):
"""Append raw events when a 'debug' marker file exists in the state dir.
Gated on a file rather than an env var because the hook inherits claude's
environment, which cannot be changed without restarting the session.
"""
marker = os.path.join(STATE_DIR, "debug")
if not os.path.exists(marker):
return
try:
with open(marker, "a") as fh:
fh.write(json.dumps(event, sort_keys=True)[:2000] + "\n")
except OSError:
pass
def derive_state(event):
"""Return the new state, 'end' to drop the session, or None to ignore."""
name = event.get("hook_event_name")
if name == "SessionEnd":
return "end"
if name == "Notification":
return NOTIFICATION_STATES.get(event.get("notification_type"))
# A subagent finishing its own turn is not the session becoming free; the
# main agent is still working. SubagentStop is a distinct event and is not
# registered, but Stop carries agent_id when raised inside an agent.
if name == "Stop" and event.get("agent_id"):
return None
return EVENT_STATES.get(name)
def read_environ(pid):
"""Environment of a process as a dict, empty if it is gone or not ours."""
try:
with open("/proc/%d/environ" % pid, "rb") as fh:
raw = fh.read()
except OSError:
return {}
env = {}
for entry in raw.split(b"\0"):
if not entry:
continue
key, sep, value = entry.partition(b"=")
if sep:
env[key.decode("utf-8", "replace")] = value.decode("utf-8", "replace")
return env
def read_cmdline(pid):
try:
with open("/proc/%d/cmdline" % pid, "rb") as fh:
return fh.read().replace(b"\0", b" ").decode("utf-8", "replace")
except OSError:
return ""
def parent_of(pid):
try:
with open("/proc/%d/status" % pid, "r") as fh:
for line in fh:
if line.startswith("PPid:"):
return int(line.split()[1])
except (OSError, ValueError):
pass
return 0
def find_claude_pid():
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
The hook is spawned through a shell, so the immediate parent is usually not
claude. Walking beyond a handful of levels risks latching onto an outer
claude when one session drives another, so the search stops early.
Returning 0 rather than guessing matters: the reader deletes state files
whose pid is gone, and the obvious fallback -- the shell that spawned this
hook -- exits milliseconds later, which would make the session flicker in
and out of the panel forever.
"""
pid = os.getppid()
for _ in range(6):
if pid <= 1:
break
# Matched anywhere in the command line, not just argv[0]: installs that
# run it as `node .../claude/cli.js` are just as valid as a direct one.
if "claude" in read_cmdline(pid):
return pid
pid = parent_of(pid)
return 0
def alive(pid):
return pid > 0 and os.path.exists("/proc/%d" % pid)
def sweep_dead(keep):
"""Drop state files whose claude process is gone.
A killed terminal never sends SessionEnd, so files leak. Cleaning up on
SessionStart keeps the sweep off the hot path -- the extension only has to
hide stale entries, not own their lifetime.
"""
try:
names = os.listdir(STATE_DIR)
except OSError:
return
for name in names:
if not name.endswith(".json") or name == keep:
continue
path = os.path.join(STATE_DIR, name)
try:
with open(path, "r") as fh:
pid = json.load(fh).get("pid", 0)
except (OSError, ValueError, AttributeError):
continue
# pid 0 means the hook could not identify the process; there is nothing
# to test for liveness, so leave it to the reader's age cutoff.
if pid and not alive(pid):
for victim in (path, path + ".lock"):
try:
os.unlink(victim)
except OSError:
pass
def write_atomic(path, payload):
tmp = "%s.%d.tmp" % (path, os.getpid())
with open(tmp, "w") as fh:
json.dump(payload, fh)
os.replace(tmp, path)
def apply_event(event, state, path, now):
"""Read the current state, decide, and write. Must run under the lock."""
if event.get("hook_event_name") == "SessionStart":
# Swept before any early return: a resumed session keeps its id, so its
# SessionStart finds an unchanged state and would otherwise bail out
# before ever reaching the sweep.
sweep_dead(keep=os.path.basename(path))
if state == "end":
for victim in (path, path + ".lock"):
try:
os.unlink(victim)
except OSError:
pass
return
previous = None
try:
with open(path, "r") as fh:
previous = json.load(fh)
except (OSError, ValueError):
previous = None
if not isinstance(previous, dict):
previous = None
# Normalised here so the comparison below is against what would actually be
# stored: comparing a stored "" to a raw notification message rewrites the
# file on every idle_prompt for no change at all.
message = event.get("message", "") if state == "blocked" else ""
if previous:
# Auto-compaction raises SessionStart again, in the middle of a turn the
# session is still working on. Taking it at face value would flip a busy
# session to idle until the next tool call corrected it.
if event.get("hook_event_name") == "SessionStart" and event.get("source") == "compact":
return
# Refuse events that lost a race with a newer one.
if previous.get("event_ts", 0) > now:
return
# Nothing new to publish: stay quiet so the directory monitor stays quiet.
if previous.get("state") == state and previous.get("message", "") == message:
return
claude_pid = find_claude_pid()
env = read_environ(claude_pid) if claude_pid else {}
write_atomic(path, {
"session_id": event.get("session_id"),
"state": state,
"cwd": event.get("cwd") or "",
# Age is measured from the moment the state was entered, not from the
# last event, so "waiting 40 min" survives unrelated later writes.
"since": previous["since"] if previous and previous.get("state") == state else now,
"event_ts": now,
# 0 means "could not tell"; the reader must not take that for "dead".
"pid": claude_pid,
"event": event.get("hook_event_name", ""),
"notification_type": event.get("notification_type", ""),
"message": message,
"zellij_session": env.get("ZELLIJ_SESSION_NAME", ""),
"zellij_pane": env.get("ZELLIJ_PANE_ID", ""),
"transcript": event.get("transcript_path", ""),
})
def main():
now = time.time()
try:
event = json.load(sys.stdin)
except (ValueError, OSError):
return 0
if not isinstance(event, dict):
return 0
session_id = event.get("session_id")
if not session_id or "/" in session_id:
return 0
debug_log(event)
state = derive_state(event)
if state is None:
return 0
os.makedirs(STATE_DIR, exist_ok=True)
path = os.path.join(STATE_DIR, "%s.json" % session_id)
# Hooks for one session run concurrently -- the last PostToolUse of a turn
# races that turn's Stop. Comparing timestamps is not enough on its own:
# without a lock both processes read the same "previous" and the loser's
# write still lands last, pinning a finished session at "busy". The lock is
# a separate file because write_atomic replaces the inode of the real one.
with open(path + ".lock", "w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX)
apply_event(event, state, path, now)
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# A hook that fails loudly would spam every session with error output;
# a missing panel update is the cheaper failure.
sys.exit(0)
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Register (or remove) the status hooks in ~/.claude/settings.json.
Merges into the existing file rather than rewriting it: settings.json holds
unrelated user configuration, and entries for other tools must survive both
install and uninstall. Ownership is tracked by the command path, so a repo
moved to a new location cleanly replaces its old registration.
Usage: install.py [--uninstall] [--settings PATH]
"""
import json
import os
import shutil
import sys
HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude-status-hook.py")
# Stop and SessionEnd are deliberately synchronous. Both fire as claude is
# about to go quiet or exit, and an async child racing that exit gets killed
# before it writes -- observed with `claude -p`, which left a session stuck at
# "busy" forever. They fire at most once per turn, so ~20 ms is free; the
# per-tool-call events stay async so they never sit in the agent's loop.
EVENTS = {
"SessionStart": True,
"UserPromptSubmit": True,
"Notification": True,
"PostToolUse": True,
"PreCompact": True,
"Stop": False,
"SessionEnd": False,
}
def entry(async_):
hook = {"type": "command", "command": HOOK, "timeout": 5}
if async_:
hook["async"] = True
return {"matcher": "", "hooks": [hook]}
def is_ours(group):
return any(
h.get("command", "").endswith("claude-status-hook.py")
for h in group.get("hooks", [])
if isinstance(h, dict)
)
def main():
uninstall = "--uninstall" in sys.argv
path = os.path.expanduser("~/.claude/settings.json")
if "--settings" in sys.argv:
path = sys.argv[sys.argv.index("--settings") + 1]
try:
with open(path) as fh:
settings = json.load(fh)
except FileNotFoundError:
settings = {}
except ValueError as exc:
sys.exit("refusing to touch malformed %s: %s" % (path, exc))
if os.path.exists(path):
shutil.copyfile(path, path + ".bak")
hooks = settings.setdefault("hooks", {})
for event in EVENTS:
groups = [g for g in hooks.get(event, []) if not is_ours(g)]
if not uninstall:
groups.append(entry(EVENTS[event]))
if groups:
hooks[event] = groups
else:
hooks.pop(event, None)
if not hooks:
settings.pop("hooks", None)
# Replaced atomically rather than truncated in place: claude re-reads
# settings.json as it changes, so a running session can be reading this
# exact file, and a truncate-then-stream write hands it invalid JSON.
tmp = "%s.%d.tmp" % (path, os.getpid())
with open(tmp, "w") as fh:
json.dump(settings, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
print("%s %s in %s" % ("removed" if uninstall else "installed", HOOK, path))
print("restart running claude sessions for the change to take effect")
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
// Formatting helpers for panel and menu labels.
import GLib from 'gi://GLib';
/** Compact age: "45s", "12m", "2h 5m". Seconds only below a minute, because
* the panel is glanced at, not read. */
export function formatAge(seconds) {
const s = Math.max(0, Math.floor(seconds));
if (s < 60)
return `${s}s`;
const m = Math.floor(s / 60);
if (m < 60)
return `${m}m`;
const h = Math.floor(m / 60);
return `${h}h ${m % 60}m`;
}
/** Last path segment, with ~ collapsed. Two worktrees of one repo share a
* basename, so this is a panel-only label; menus show the full path. */
export function projectName(path) {
if (!path)
return '?';
const trimmed = path.replace(/\/+$/, '');
const base = trimmed.split('/').pop();
return base || trimmed || '?';
}
/** Full path with the home directory shortened to "~". */
export function shortenHome(path) {
if (!path)
return '';
const home = GLib.get_home_dir();
if (home && path.startsWith(home))
return `~${path.slice(home.length)}`;
return path;
}
+394
View File
@@ -0,0 +1,394 @@
// Panel button: one glance answers "does anything need me, and where".
import GObject from 'gi://GObject';
import St from 'gi://St';
import Clutter from 'gi://Clutter';
import GLib from 'gi://GLib';
import Pango from 'gi://Pango';
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js';
import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js';
import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js';
import { SessionStore, STATES } from './sessions.js';
import { ZellijTabs } from './zellij.js';
import { formatAge, projectName, shortenHome } from './format.js';
// Translated lazily: gettext is not bound yet while modules are being imported.
function stateLabel(state) {
switch (state) {
case 'blocked': return _('needs an answer');
case 'waiting': return _('waiting for input');
case 'busy': return _('working');
case 'idle': return _('idle');
default: return state;
}
}
const TERMINAL_CLASSES = [
'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty',
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
];
export const ClaudeStatusIndicator = GObject.registerClass(
class ClaudeStatusIndicator extends PanelMenu.Button {
_init(extension) {
super._init(0.5, 'Claude Code Status', false);
this._extension = extension;
this._settings = extension.getSettings();
this._store = new SessionStore();
this._zellij = new ZellijTabs();
this._dotState = 'none';
this._rows = [];
this._buildPanel();
this._buildMenu();
this._changedId = this._store.connect('changed', () => this._update());
this._settingsChangedId = this._settings.connect('changed', () => this._update());
this._store.start();
}
// ---- Panel widget -------------------------------------------------
_buildPanel() {
const box = new St.BoxLayout({
style_class: 'panel-status-menu-box ccs-panel-box',
y_align: Clutter.ActorAlign.CENTER,
});
// Shape carries the state as well as colour does, so the indicator
// still reads under a monochrome theme or with colour vision deficiency:
// filled disc in a ring — blocked on a permission prompt;
// filled disc — turn finished, waiting for input;
// bright ring — working;
// dim dashed ring — idle, or nothing running.
this._dot = new St.DrawingArea({
style_class: 'ccs-dot ccs-none',
y_align: Clutter.ActorAlign.CENTER,
});
this._dot.set_width(16);
this._dot.set_height(16);
this._dot.connect('repaint', area => this._drawDot(area));
box.add_child(this._dot);
this._label = new St.Label({
style_class: 'ccs-panel-label',
y_align: Clutter.ActorAlign.CENTER,
text: '',
});
box.add_child(this._label);
this.add_child(box);
}
// ---- Menu ---------------------------------------------------------
_buildMenu() {
this._summaryItem = new PopupMenu.PopupMenuItem('', {
reactive: false,
can_focus: false,
});
this._summaryItem.add_style_class_name('ccs-summary');
this.menu.addMenuItem(this._summaryItem);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
this._sessionsSection = new PopupMenu.PopupMenuSection();
this.menu.addMenuItem(this._sessionsSection);
this.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem());
const prefsItem = new PopupMenu.PopupMenuItem(_('Settings'));
prefsItem.connect('activate', () => this._extension.openPreferences());
this.menu.addMenuItem(prefsItem);
this.menu.connect('open-state-changed', (_menu, open) => {
if (open)
this._refreshZellij(true);
});
}
// ---- Data → UI ----------------------------------------------------
_update() {
const sessions = this._store.sessions;
this._updatePanel(sessions);
this._updateMenu(sessions);
this._refreshZellij(false);
}
_updatePanel(sessions) {
const hideWhenIdle = this._settings.get_boolean('hide-when-idle');
const showName = this._settings.get_boolean('show-project-name');
const showAge = this._settings.get_boolean('show-age');
const top = sessions[0] ?? null;
const state = top?.state ?? 'none';
// Only sessions sharing the top state are counted: "+2" must mean two
// more that need the same thing, not two unrelated background sessions.
const peers = top ? sessions.filter(s => s.state === top.state).length : 0;
this.visible = !(hideWhenIdle && (!top || top.state === 'idle'));
let text = '';
if (top && top.state !== 'idle') {
const parts = [];
if (showName)
parts.push(projectName(top.cwd));
if (showAge)
parts.push(formatAge(this._ageOf(top)));
text = parts.join(' · ');
if (peers > 1)
text = text ? `${text} +${peers - 1}` : `+${peers - 1}`;
}
this._label.text = text;
this._label.visible = text !== '';
if (state !== this._dotState) {
this._dotState = state;
this._dot.style_class = `ccs-dot ccs-${state}`;
this._dot.queue_repaint();
}
}
_updateMenu(sessions) {
this._summaryItem.label.text = this._summaryText(sessions);
// Rebuild only when the set of sessions or their states changed; ages
// alone are refreshed in place so an open menu does not flicker.
const signature = sessions
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}`)
.join('|');
if (signature !== this._rowSignature) {
this._rebuildRows(sessions);
this._rowSignature = signature;
}
for (const row of this._rows) {
const session = sessions.find(s => s.sessionId === row.sessionId);
if (!session)
continue;
row.age.text = formatAge(this._ageOf(session));
row.subtitle.text = this._subtitleFor(session);
}
}
_summaryText(sessions) {
if (!sessions.length)
return _('No Claude Code sessions');
const counts = new Map();
for (const s of sessions)
counts.set(s.state, (counts.get(s.state) ?? 0) + 1);
const parts = [];
for (const state of STATES) {
const n = counts.get(state);
if (n)
parts.push(`${n} ${stateLabel(state)}`);
}
return parts.join(', ');
}
_rebuildRows(sessions) {
this._sessionsSection.removeAll();
this._rows = [];
if (!sessions.length) {
const empty = new PopupMenu.PopupMenuItem(_('Nothing running'), {
reactive: false,
can_focus: false,
});
this._sessionsSection.addMenuItem(empty);
return;
}
for (const session of sessions)
this._sessionsSection.addMenuItem(this._buildRow(session));
}
_buildRow(session) {
const tab = this._tabFor(session);
// Reactivity is decided at construction, not patched afterwards:
// PopupBaseMenuItem latches _activatable in its constructor, so a row
// switched to reactive=false later keeps the styling of a clickable one
// and still looks like it does something.
const item = new PopupMenu.PopupBaseMenuItem(
tab ? {} : { reactive: false, can_focus: false });
item.add_style_class_name('ccs-row');
const column = new St.BoxLayout({ vertical: true, x_expand: true });
const top = new St.BoxLayout({ x_expand: true });
const title = new St.Label({
text: projectName(session.cwd),
style_class: `ccs-row-title ccs-${session.state}`,
x_expand: true,
});
const age = new St.Label({
text: formatAge(this._ageOf(session)),
style_class: 'ccs-row-age',
x_align: Clutter.ActorAlign.END,
});
top.add_child(title);
top.add_child(age);
const subtitle = new St.Label({
text: this._subtitleFor(session),
style_class: 'ccs-row-subtitle',
});
subtitle.clutter_text.line_wrap = false;
subtitle.clutter_text.ellipsize = Pango.EllipsizeMode.END;
column.add_child(top);
column.add_child(subtitle);
item.add_child(column);
if (tab)
item.connect('activate', () => this._switchTo(session, tab));
this._rows.push({ sessionId: session.sessionId, age, subtitle });
return item;
}
/** Second line: what the session needs, then where to find it. */
_subtitleFor(session) {
const where = [];
const tab = this._tabFor(session);
if (tab)
where.push(`${_('tab')}: ${tab}`);
else if (session.zellijSession)
where.push(`${_('zellij')}: ${session.zellijSession}`);
where.push(shortenHome(session.cwd));
// A permission prompt is the one case where the reason matters more
// than the location: it says what is about to run.
if (session.state === 'blocked' && session.message)
return `${session.message}${where.join(' · ')}`;
return `${stateLabel(session.state)} · ${where.join(' · ')}`;
}
_ageOf(session) {
if (!session.since)
return 0;
return GLib.get_real_time() / 1e6 - session.since;
}
// ---- zellij ---------------------------------------------------------
_tabFor(session) {
if (!this._settings.get_boolean('zellij-integration'))
return null;
return this._zellij.tabFor(session.zellijSession, session.cwd);
}
_refreshZellij(force) {
if (!this._settings.get_boolean('zellij-integration'))
return;
const names = this._store.sessions.map(s => s.zellijSession).filter(Boolean);
if (!names.length)
return;
this._zellij.refresh(names, force)
.then(() => {
if (!this._destroyed)
this._updateMenu(this._store.sessions);
})
.catch(e => logError(e, 'claude-code-status: zellij refresh failed'));
}
_switchTo(session, tab) {
this._zellij.goToTab(session.zellijSession, tab);
this._focusTerminal(session);
}
/** Best effort: raise a terminal window showing this zellij session.
*
* Matching by pid does not work for gnome-terminal, where every window
* belongs to one shared server process, so the window title is the only
* handle available -- and zellij puts the session name there.
*/
_focusTerminal(session) {
if (!session.zellijSession)
return;
// list_all_windows() rather than get_window_actors(): the latter is
// deprecated from GNOME 46 on, and this has to work across 45-48.
for (const win of global.display.list_all_windows()) {
const wmClass = (win.get_wm_class() ?? '').toLowerCase();
if (!TERMINAL_CLASSES.some(c => wmClass.includes(c)))
continue;
// Only a window that names this zellij session is raised. Falling
// back to any terminal at all would raise an unrelated one, which
// is worse than leaving focus where the user put it.
if ((win.get_title() ?? '').includes(session.zellijSession)) {
Main.activateWindow(win);
return;
}
}
}
// ---- Visuals --------------------------------------------------------
_drawDot(area) {
const cr = area.get_context();
try {
const [w, h] = area.get_surface_size();
const color = area.get_theme_node().get_foreground_color();
const r = color.red / 255;
const g = color.green / 255;
const b = color.blue / 255;
const a = color.alpha / 255;
const cx = w / 2;
const cy = h / 2;
const radius = Math.min(w, h) / 2 - 2;
cr.setLineWidth(1.5);
cr.setSourceRGBA(r, g, b, a);
switch (this._dotState) {
case 'blocked':
// Disc inside a ring: the loudest shape, for the only state
// where a session is stuck until you act.
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
cr.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI);
cr.fill();
break;
case 'waiting':
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.fill();
break;
case 'busy':
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
break;
default:
cr.setDash([2, 2], 0);
cr.setSourceRGBA(r, g, b, a * 0.5);
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
cr.stroke();
cr.setDash([], 0);
break;
}
} finally {
cr.$dispose();
}
}
// ---- Teardown -------------------------------------------------------
destroy() {
this._destroyed = true;
this._zellij.destroy();
if (this._changedId) {
this._store.disconnect(this._changedId);
this._changedId = 0;
}
if (this._settingsChangedId) {
this._settings.disconnect(this._settingsChangedId);
this._settingsChangedId = 0;
}
this._store.destroy();
super.destroy();
}
});
+235
View File
@@ -0,0 +1,235 @@
// Reads the per-session state files written by the Claude Code hook and keeps
// them in sync with the filesystem.
//
// The hook only writes on an actual state change, so a directory monitor is
// enough and there is nothing to poll. The timer here exists for two other
// reasons: displayed ages go stale on their own, and a session whose terminal
// was killed never sends SessionEnd, so liveness has to be rechecked.
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
// Aggregation order: a session blocked on a permission prompt is the only one
// that is actually stuck, so it outranks one that merely finished its turn.
export const STATES = ['blocked', 'waiting', 'busy', 'idle'];
const KNOWN = new Set(STATES);
const LIVENESS_INTERVAL = 20; // seconds
// Fallback for sessions whose process could not be identified (pid 0): there is
// nothing to test for liveness, so age is the only signal left. Long enough
// that a session genuinely left waiting overnight is still listed in the
// morning, which is exactly the case this indicator exists for.
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
export function stateRank(state) {
const i = STATES.indexOf(state);
return i < 0 ? STATES.length : i;
}
export function stateDir() {
const base = GLib.getenv('XDG_STATE_HOME') ||
GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']);
return GLib.build_filenamev([base, 'claude-code-status']);
}
export const SessionStore = GObject.registerClass({
Signals: { 'changed': {} },
}, class SessionStore extends GObject.Object {
_init() {
super._init();
this._dir = Gio.File.new_for_path(stateDir());
this._sessions = [];
this._monitor = null;
this._debounceId = 0;
this._timerId = 0;
this._cancellable = new Gio.Cancellable();
this._loading = false;
this._loadAgain = false;
}
get sessions() {
return this._sessions;
}
start() {
// The directory is created by the first hook run, which may not have
// happened yet; monitoring a missing directory still reports its
// creation, so there is nothing to wait for.
try {
this._monitor = this._dir.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, null);
this._monitor.connect('changed', () => this._scheduleLoad());
} catch (e) {
logError(e, 'claude-code-status: cannot monitor state directory');
}
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LIVENESS_INTERVAL, () => {
// Ages advance and processes die without any file changing, so this
// tick is what makes a killed terminal disappear from the panel.
this._load();
return GLib.SOURCE_CONTINUE;
});
this._load();
}
// One atomic write lands as several monitor events (created, moved, changed).
// Collapsing them keeps a burst of five sessions from causing five reloads.
_scheduleLoad() {
if (this._debounceId)
GLib.Source.remove(this._debounceId);
this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._debounceId = 0;
this._load();
return GLib.SOURCE_REMOVE;
});
}
async _load() {
if (this._loading) {
this._loadAgain = true;
return;
}
this._loading = true;
const cancellable = this._cancellable;
try {
const sessions = await this._readAll(cancellable);
if (cancellable.is_cancelled())
return;
sessions.sort((a, b) => {
const byState = stateRank(a.state) - stateRank(b.state);
// Oldest first within a state: the session you forgot about is
// the one that has been waiting longest, not the latest one.
return byState !== 0 ? byState : a.since - b.since;
});
this._sessions = sessions;
// Emitted unconditionally: even with no structural change the
// displayed ages have advanced, and redrawing a handful of labels
// is cheaper than tracking what moved.
this.emit('changed');
} catch (e) {
if (!cancellable.is_cancelled())
logError(e, 'claude-code-status: failed to read session state');
} finally {
this._loading = false;
if (this._loadAgain) {
this._loadAgain = false;
this._load();
}
}
}
async _readAll(cancellable) {
let enumerator;
try {
enumerator = await this._dir.enumerate_children_async(
'standard::name', Gio.FileQueryInfoFlags.NONE,
GLib.PRIORITY_DEFAULT, cancellable);
} catch (e) {
// No directory yet means no sessions have ever run; not an error.
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
return [];
throw e;
}
const names = [];
for (;;) {
const batch = await enumerator.next_files_async(
32, GLib.PRIORITY_DEFAULT, cancellable);
if (!batch.length)
break;
for (const info of batch) {
const name = info.get_name();
// ".tmp" files are half-written state; "debug" is the hook's
// opt-in event log and is not a session.
if (name.endsWith('.json'))
names.push(name);
}
}
const sessions = [];
for (const name of names) {
const session = await this._readOne(name, cancellable);
if (session)
sessions.push(session);
}
return sessions;
}
async _readOne(name, cancellable) {
const file = this._dir.get_child(name);
let raw;
try {
const [contents] = await file.load_contents_async(cancellable);
raw = JSON.parse(new TextDecoder().decode(contents));
} catch (e) {
// A file replaced mid-read, or truncated by a crash: skip it and
// let the next monitor event pick up the good version.
return null;
}
if (!raw || typeof raw !== 'object')
return null;
const pid = Number(raw.pid) || 0;
const eventTs = Number(raw.event_ts) || 0;
const age = GLib.get_real_time() / 1e6 - eventTs;
// pid 0 is "the hook could not tell", not "dead": treating it as dead
// would hide a perfectly live session, so those fall back to an age
// cutoff instead.
const gone = pid > 0 ? !isAlive(pid) : age > UNKNOWN_PID_MAX_AGE;
if (gone) {
// The terminal was killed without a SessionEnd hook. Removing the
// file here (rather than only hiding it) keeps the directory from
// growing forever across reboots.
// The hook's lock file goes with it; dropping only the state file
// would leave one empty ".lock" behind per session, forever.
for (const victim of [file, this._dir.get_child(`${name}.lock`)]) {
victim.delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Already gone: the hook's own sweep got there first.
}
});
}
return null;
}
const state = KNOWN.has(raw.state) ? raw.state : 'idle';
return {
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
state,
cwd: String(raw.cwd ?? ''),
since: Number(raw.since) || 0,
pid,
message: String(raw.message ?? ''),
notificationType: String(raw.notification_type ?? ''),
zellijSession: String(raw.zellij_session ?? ''),
zellijPane: String(raw.zellij_pane ?? ''),
};
}
destroy() {
this._cancellable.cancel();
if (this._debounceId) {
GLib.Source.remove(this._debounceId);
this._debounceId = 0;
}
if (this._timerId) {
GLib.Source.remove(this._timerId);
this._timerId = 0;
}
this._monitor?.cancel();
this._monitor = null;
this._sessions = [];
}
});
function isAlive(pid) {
return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS);
}
+162
View File
@@ -0,0 +1,162 @@
// Maps a session's working directory to the zellij tab it is running in.
//
// Knowing a session waits for you is only half the answer; the other half is
// where to look. When sessions live in zellij tabs, the tab name is a better
// answer than a path, and zellij can be told to switch to it.
//
// `zellij action dump-layout` prints tab names with each pane's cwd but no pane
// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the
// match goes through the working directory instead. That is best-effort by
// nature: two sessions in one tab are indistinguishable, and a session whose
// cwd has moved since the pane opened will not match.
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
// Layouts only change when tabs are rearranged, and opening the menu forces a
// refresh anyway -- so the background TTL is long enough that the periodic tick
// does not spawn a zellij process every time it runs.
const CACHE_TTL = 120; // seconds
export class ZellijTabs {
constructor() {
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
this._inFlight = new Map();
this._available = null;
this._cancellable = new Gio.Cancellable();
}
/** Tab name for a working directory, or null when unknown. */
tabFor(zellijSession, cwd) {
const entry = this._cache.get(zellijSession);
if (!entry || !cwd)
return null;
// Longest matching prefix wins: a pane opened at the repo root must not
// outrank one opened directly in the subdirectory the session runs in.
let best = null;
let bestLen = -1;
for (const tab of entry.tabs) {
for (const paneCwd of tab.cwds) {
if (cwd !== paneCwd && !cwd.startsWith(`${paneCwd}/`))
continue;
if (paneCwd.length > bestLen) {
bestLen = paneCwd.length;
best = tab.name;
}
}
}
return best;
}
/** Refresh the layout of every zellij session in use, at most once per TTL.
* `force` bypasses the TTL for the one moment it matters: the user just
* opened the menu and may have rearranged tabs since the last look. */
async refresh(zellijSessions, force = false) {
if (this._available === false)
return;
const now = GLib.get_monotonic_time() / 1e6;
const work = [];
for (const name of new Set(zellijSessions)) {
if (!name)
continue;
const entry = this._cache.get(name);
if (!force && entry && now - entry.at < CACHE_TTL)
continue;
work.push(this._refreshOne(name, now));
}
await Promise.all(work);
}
async _refreshOne(name, now) {
// Collapse concurrent refreshes of the same session; the menu opening
// and the periodic tick can otherwise fire two subprocesses at once.
if (this._inFlight.has(name))
return this._inFlight.get(name);
const promise = this._dumpLayout(name)
.then(layout => {
if (layout !== null)
this._available = true;
// A failure is cached as an empty layout, not left uncached:
// otherwise a zellij session that was renamed or killed while
// its claude process lives on fails the TTL check every time
// and forks a process on every tick, forever.
this._cache.set(name, {
at: now,
tabs: layout === null ? [] : parseLayout(layout),
});
})
.finally(() => this._inFlight.delete(name));
this._inFlight.set(name, promise);
return promise;
}
async _dumpLayout(session) {
try {
const proc = Gio.Subprocess.new(
['zellij', '--session', session, 'action', 'dump-layout'],
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
if (!proc.get_successful())
return null;
return stdout ?? '';
} catch (e) {
// zellij not installed, or not on the shell's PATH: stop trying.
if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT))
this._available = false;
return null;
}
}
/** Abandon any layout dump still running; the extension is going away. */
destroy() {
this._cancellable.cancel();
this._cache.clear();
this._inFlight.clear();
}
/** Switch the given zellij session to a tab. Fire and forget. */
goToTab(session, tab) {
try {
Gio.Subprocess.new(
['zellij', '--session', session, 'action', 'go-to-tab-name', tab],
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE);
} catch (e) {
logError(e, 'claude-code-status: zellij go-to-tab-name failed');
}
}
}
/** Extract tab names and their pane working directories from a KDL layout.
*
* Parsed with line matching rather than a KDL parser: the two constructs that
* matter are one line each, and a dependency-free extension cannot pull one in.
*/
export function parseLayout(text) {
const tabs = [];
// A layout-level `cwd "..."` is the base for panes that store a relative one.
const baseMatch = text.match(/^\s*cwd\s+"([^"]*)"/m);
const base = baseMatch ? baseMatch[1] : '';
let current = null;
for (const line of text.split('\n')) {
const tabMatch = line.match(/^\s*tab\s.*?name="([^"]*)"/);
if (tabMatch) {
current = { name: tabMatch[1], cwds: [] };
tabs.push(current);
continue;
}
if (!current)
continue;
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
if (paneMatch) {
const cwd = paneMatch[1];
const absolute = cwd.startsWith('/')
? cwd
: GLib.build_filenamev([base, cwd]);
if (!current.cwds.includes(absolute))
current.cwds.push(absolute);
}
}
return tabs;
}
+10
View File
@@ -0,0 +1,10 @@
{
"uuid": "claude-code-status@git.vakhrushev.me",
"name": "Claude Code Status",
"description": "Shows what your Claude Code sessions are doing in the top bar: which one is blocked on a permission prompt, which one finished and waits for input, and which one is still working. Fed by Claude Code hooks, so it updates the moment a session changes state.",
"shell-version": ["45", "46", "47", "48"],
"url": "https://git.vakhrushev.me/av/claude-code-gnome-extension",
"settings-schema": "org.gnome.shell.extensions.claude-code-status",
"gettext-domain": "claude-code-status",
"version": 1
}
+106
View File
@@ -0,0 +1,106 @@
import Adw from 'gi://Adw';
import Gtk from 'gi://Gtk';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import { ExtensionPreferences, gettext as _ } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
fillPreferencesWindow(window) {
const settings = this.getSettings();
const page = new Adw.PreferencesPage({
title: _('General'),
icon_name: 'utilities-terminal-symbolic',
});
window.add(page);
const dispGroup = new Adw.PreferencesGroup({ title: _('Panel') });
page.add(dispGroup);
dispGroup.add(this._switchRow(settings, 'show-project-name',
_('Show project name'),
_('Name the session that needs attention, not just its state.')));
dispGroup.add(this._switchRow(settings, 'show-age',
_('Show time in state'),
_('How long it has been working, or waiting for you.')));
dispGroup.add(this._switchRow(settings, 'hide-when-idle',
_('Hide when nothing is running'),
_('Remove the indicator from the panel while no session is active.')));
const zellijGroup = new Adw.PreferencesGroup({
title: _('zellij'),
description: _('Sessions running inside zellij can be located by tab name instead of by path.'),
});
page.add(zellijGroup);
zellijGroup.add(this._switchRow(settings, 'zellij-integration',
_('Resolve tab names'),
_('Show the zellij tab in the menu and switch to it on click. Ignored when zellij is not installed.')));
// --- Hooks ---------------------------------------------------------
// The indicator is only as good as the hooks feeding it, and a silent
// panel looks identical whether nothing is running or nothing is
// installed. Say which it is.
const hooksGroup = new Adw.PreferencesGroup({
title: _('Hooks'),
description: _('Claude Code writes one state file per session; the panel watches them.'),
});
page.add(hooksGroup);
hooksGroup.add(new Adw.ActionRow({
title: _('Status'),
subtitle: this._hooksStatus(),
}));
hooksGroup.add(new Adw.ActionRow({
title: _('State directory'),
subtitle: this._stateDir(),
}));
const installRow = new Adw.ActionRow({
title: _('Install command'),
subtitle: `${this.path}/hooks/install.py`,
});
const copyButton = new Gtk.Button({
icon_name: 'edit-copy-symbolic',
valign: Gtk.Align.CENTER,
tooltip_text: _('Copy to clipboard'),
});
copyButton.connect('clicked', () => {
window.get_clipboard().set(`${this.path}/hooks/install.py`);
});
installRow.add_suffix(copyButton);
hooksGroup.add(installRow);
}
_stateDir() {
const base = GLib.getenv('XDG_STATE_HOME') ||
GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']);
return GLib.build_filenamev([base, 'claude-code-status']);
}
_hooksStatus() {
const path = GLib.build_filenamev([GLib.get_home_dir(), '.claude', 'settings.json']);
try {
const [ok, bytes] = GLib.file_get_contents(path);
if (!ok)
return _('~/.claude/settings.json is unreadable');
const settings = JSON.parse(new TextDecoder().decode(bytes));
const events = Object.entries(settings.hooks ?? {})
.filter(([, groups]) => groups.some(g =>
(g.hooks ?? []).some(h => (h.command ?? '').includes('claude-status-hook.py'))))
.map(([event]) => event);
if (!events.length)
return _('Not installed — run the install command below, then restart your sessions');
return `${_('Installed for')}: ${events.join(', ')}`;
} catch (e) {
return _('~/.claude/settings.json could not be parsed');
}
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
return row;
}
}
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist>
<schema id="org.gnome.shell.extensions.claude-code-status"
path="/org/gnome/shell/extensions/claude-code-status/">
<key name="show-project-name" type="b">
<default>true</default>
<summary>Show the project name in the panel</summary>
<description>Name the session that needs attention, not just its state. With several sessions running, the state alone does not say which terminal to go to.</description>
</key>
<key name="show-age" type="b">
<default>true</default>
<summary>Show how long the session has been in this state</summary>
<description>How long a session has been working, or waiting, is what decides whether to switch to it now or leave it running.</description>
</key>
<key name="hide-when-idle" type="b">
<default>false</default>
<summary>Hide the indicator when nothing is running</summary>
<description>Remove the indicator from the panel while there are no sessions, or all of them are idle.</description>
</key>
<key name="zellij-integration" type="b">
<default>true</default>
<summary>Resolve zellij tab names</summary>
<description>Look up which zellij tab each session runs in, show it in the menu, and let a click switch to that tab. Requires the zellij command; harmless when it is absent.</description>
</key>
</schema>
</schemalist>
+48
View File
@@ -0,0 +1,48 @@
/* The dot is drawn with the widget's foreground colour, so state colours are
set here as plain `color` and the drawing code stays theme-agnostic. */
.ccs-panel-box {
spacing: 4px;
}
.ccs-panel-label {
font-size: 0.9em;
}
.ccs-dot.ccs-blocked,
.ccs-row-title.ccs-blocked {
color: #e01b24;
}
.ccs-dot.ccs-waiting,
.ccs-row-title.ccs-waiting {
color: #e5a50a;
}
.ccs-dot.ccs-busy,
.ccs-row-title.ccs-busy {
color: #33d17a;
}
.ccs-summary {
font-weight: bold;
}
.ccs-row {
spacing: 8px;
}
.ccs-row-title {
font-weight: bold;
}
.ccs-row-age {
font-feature-settings: "tnum";
opacity: 0.7;
}
.ccs-row-subtitle {
font-size: 0.85em;
opacity: 0.65;
max-width: 460px;
}
+116
View File
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Exercises the hook's state machine and its behaviour under concurrency.
#
# Run: tests/test-hook.sh
set -u
HOOK="$(cd "$(dirname "$0")/.." && pwd)/hooks/claude-status-hook.py"
export XDG_STATE_HOME="$(mktemp -d)"
DIR="$XDG_STATE_HOME/claude-code-status"
SID="test-session"
FILE="$DIR/$SID.json"
failures=0
check() { # name expected actual
if [ "$2" = "$3" ]; then
echo "ok $1"
else
echo "FAIL $1 (expected '$2', got '$3')"
failures=$((failures + 1))
fi
}
emit() { # json
echo "$1" | "$HOOK"
}
field() { # key
python3 -c "import json,sys;print(json.load(open('$FILE')).get('$1',''))" 2>/dev/null
}
ev() { # event [extra json]
echo "{\"session_id\":\"$SID\",\"hook_event_name\":\"$1\",\"cwd\":\"/tmp/proj\"${2:+,$2}}"
}
# --- state machine ---------------------------------------------------------
emit "$(ev SessionStart '"source":"startup"')"
check "SessionStart -> idle" "idle" "$(field state)"
emit "$(ev UserPromptSubmit '"prompt":"hi"')"
check "UserPromptSubmit -> busy" "busy" "$(field state)"
before=$(field event_ts)
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse while busy does not rewrite" "$before" "$(field event_ts)"
emit "$(ev Notification '"notification_type":"permission_prompt","message":"run rm -rf"')"
check "permission_prompt -> blocked" "blocked" "$(field state)"
check "permission message kept" "run rm -rf" "$(field message)"
emit "$(ev PostToolUse '"tool_name":"Bash"')"
check "PostToolUse clears blocked" "busy" "$(field state)"
emit "$(ev Notification '"notification_type":"auth_success","message":"logged in"')"
check "auth_success ignored" "busy" "$(field state)"
emit "$(ev Stop)"
check "Stop -> waiting" "waiting" "$(field state)"
before=$(field event_ts)
emit "$(ev Notification '"notification_type":"idle_prompt","message":"waiting for input"')"
check "idle_prompt while waiting does not rewrite" "$before" "$(field event_ts)"
emit "$(ev Stop '"agent_id":"sub-1"')"
check "subagent Stop ignored" "waiting" "$(field state)"
since_before=$(field since)
emit "$(ev SessionStart '"source":"compact"')"
check "compaction does not reset a live session" "waiting" "$(field state)"
check "compaction does not reset the clock" "$since_before" "$(field since)"
# --- concurrency -----------------------------------------------------------
# The hazard: the last PostToolUse of a turn is async and can still be running
# when the turn's Stop fires. Two mechanisms defend against it and they are
# tested separately, because the burst below passes on the timestamp guard
# alone -- it does not prove the lock is doing anything.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
for _ in $(seq 30); do
emit "$(ev PostToolUse '"tool_name":"Bash"')" &
done
sleep 0.3 # let the racers start, so Stop's timestamp is genuinely later
emit "$(ev Stop)"
wait
check "Stop survives a burst of concurrent PostToolUse" "waiting" "$(field state)"
python3 -c "import json;json.load(open('$FILE'))" 2>/dev/null
check "state file is still valid JSON" "0" "$?"
# The lock itself: what the timestamp guard cannot cover is one hook reading the
# old state, being descheduled while another writes, then writing its own stale
# decision on top. Holding the lock from outside makes that window observable --
# a hook must wait for it rather than read-and-write straight through.
emit "$(ev UserPromptSubmit '"prompt":"go"')"
python3 -c "
import fcntl, time
with open('$FILE.lock', 'w') as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
time.sleep(1.5)
" &
holder=$!
sleep 0.4
emit "$(ev Stop)" &
sleep 0.5
check "hook blocks while the lock is held" "busy" "$(field state)"
wait $holder
wait
check "hook proceeds once the lock is released" "waiting" "$(field state)"
# --- teardown --------------------------------------------------------------
emit "$(ev SessionEnd '"reason":"other"')"
[ -e "$FILE" ]; check "SessionEnd removes the state file" "1" "$?"
[ -e "$FILE.lock" ]; check "SessionEnd removes the lock file" "1" "$?"
rm -rf "$XDG_STATE_HOME"
if [ "$failures" -gt 0 ]; then
echo; echo "$failures failure(s)"; exit 1
fi
echo; echo "all passed"
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/gjs -m
// Exercises SessionStore against real files, under real GJS/GIO.
//
// sessions.js deliberately imports nothing from resource:///org/gnome/shell,
// which is what makes this runnable outside the compositor -- the part of the
// extension most likely to be wrong (liveness, ordering, monitoring, partial
// reads) is also the part that can be tested for real.
//
// Run: gjs -m tests/test-sessions.js
import GLib from 'gi://GLib';
import Gio from 'gi://Gio';
import { SessionStore, stateRank } from '../lib/sessions.js';
const DIR = GLib.build_filenamev([GLib.get_tmp_dir(), `ccs-test-${GLib.random_int()}`]);
GLib.setenv('XDG_STATE_HOME', DIR, true);
const STATE = GLib.build_filenamev([DIR, 'claude-code-status']);
GLib.mkdir_with_parents(STATE, 0o755);
let failures = 0;
function check(name, condition, detail = '') {
const mark = condition ? 'ok ' : 'FAIL';
if (!condition)
failures++;
print(`${mark} ${name}${detail ? ` (${detail})` : ''}`);
}
const now = GLib.get_real_time() / 1e6;
function write(id, state, cwd, agoSeconds, pid) {
const payload = {
session_id: id, state, cwd, since: now - agoSeconds,
event_ts: now, pid, event: 'test', notification_type: '',
message: '', zellij_session: 'ztest', zellij_pane: '1', transcript: '',
};
GLib.file_set_contents(
GLib.build_filenamev([STATE, `${id}.json`]), JSON.stringify(payload));
}
// A process that exists for the duration of the test, and one that does not.
const livePid = new TextDecoder().decode(
GLib.file_get_contents('/proc/self/stat')[1]).split(' ')[0];
const deadPid = 4194303; // above the default pid_max, so it cannot exist
write('s-busy', 'busy', '/home/u/proj-busy', 30, livePid);
write('s-blocked', 'blocked', '/home/u/proj-blocked', 10, livePid);
write('s-wait-new', 'waiting', '/home/u/proj-new', 60, livePid);
write('s-wait-old', 'waiting', '/home/u/proj-old', 3600, livePid);
write('s-dead', 'waiting', '/home/u/proj-dead', 5, deadPid);
write('s-idle', 'idle', '/home/u/proj-idle', 5, livePid);
GLib.file_set_contents(GLib.build_filenamev([STATE, 'notes.txt']), 'ignored');
GLib.file_set_contents(GLib.build_filenamev([STATE, 'half.json']), '{"broken');
const loop = new GLib.MainLoop(null, false);
const store = new SessionStore();
let round = 0;
store.connect('changed', () => {
round++;
const s = store.sessions;
if (round === 1) {
check('ranks order blocked before waiting before busy',
stateRank('blocked') < stateRank('waiting') &&
stateRank('waiting') < stateRank('busy'));
check('dead session dropped', !s.some(x => x.sessionId === 's-dead'));
check('truncated file skipped, others survive', s.length === 5,
`got ${s.length}: ${s.map(x => x.sessionId).join(',')}`);
check('non-json ignored', !s.some(x => x.sessionId.includes('notes')));
check('blocked sorts first', s[0]?.sessionId === 's-blocked', s[0]?.sessionId);
check('longest wait precedes newer wait',
s[1]?.sessionId === 's-wait-old' && s[2]?.sessionId === 's-wait-new',
`${s[1]?.sessionId}, ${s[2]?.sessionId}`);
check('busy after waiting', s[3]?.sessionId === 's-busy', s[3]?.sessionId);
check('idle last', s[4]?.sessionId === 's-idle', s[4]?.sessionId);
check('dead session file removed from disk',
!GLib.file_test(GLib.build_filenamev([STATE, 's-dead.json']), GLib.FileTest.EXISTS));
// A hook writing a new session must reach the panel without polling.
write('s-fresh', 'blocked', '/home/u/proj-fresh', 1, livePid);
return;
}
if (s.some(x => x.sessionId === 's-fresh')) {
check('directory monitor picked up a new session', true);
check('new blocked session takes the top slot',
s[0].state === 'blocked', s[0].sessionId);
store.destroy();
loop.quit();
}
});
store.start();
GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 10, () => {
check('finished before timeout', false, 'monitor never fired');
loop.quit();
return GLib.SOURCE_REMOVE;
});
loop.run();
Gio.File.new_for_path(DIR).trash_async?.(GLib.PRIORITY_LOW, null, null);
GLib.spawn_command_line_sync(`rm -rf ${DIR}`);
print(failures ? `\n${failures} failure(s)` : '\nall passed');
imports.system.exit(failures ? 1 : 0);