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:
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user