// 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(); } });