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
+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;
}
}