// 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 { assignChips } from './abbrev.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', ]; /** Paint the state glyph in the actor's inherited foreground colour, so the * stylesheet decides the colour and this stays theme-agnostic. */ function drawStateDot(area, state) { 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 - 1.5; cr.setLineWidth(1.5); cr.setSourceRGBA(r, g, b, a); switch (state) { 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.5, 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(); } } 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._chipLabels = new Map(); 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() { // One chip per session rather than one aggregate: with five projects // open, "the most urgent one" answers a question you did not ask. The // row sits right of the clock, so it grows away from the centre. this._chipBox = new St.BoxLayout({ style_class: 'panel-status-menu-box ccs-panel-box', y_align: Clutter.ActorAlign.CENTER, }); this.add_child(this._chipBox); } _buildChip(session, label, withAge) { const chip = new St.BoxLayout({ style_class: `ccs-chip ccs-${session.state}`, y_align: Clutter.ActorAlign.CENTER, }); // Shape carries the state as well as colour does, so the row 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. const dot = new St.DrawingArea({ style_class: 'ccs-dot', y_align: Clutter.ActorAlign.CENTER, }); dot.set_width(12); dot.set_height(12); dot.connect('repaint', area => drawStateDot(area, session.state)); chip.add_child(dot); let age = null; if (this._settings.get_boolean('show-project-name')) { chip.add_child(new St.Label({ style_class: 'ccs-chip-label', y_align: Clutter.ActorAlign.CENTER, text: label, })); } if (withAge) { age = new St.Label({ style_class: 'ccs-chip-age', y_align: Clutter.ActorAlign.CENTER, text: formatAge(this._ageOf(session)), }); chip.add_child(age); } return { chip, age }; } // ---- 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 showAge = this._settings.get_boolean('show-age'); const abbreviate = this._settings.get_boolean('abbreviate-names'); this._chipLabels = assignChips( sessions.map(s => ({ sessionId: s.sessionId, since: s.since, base: projectName(s.cwd), })), this._chipLabels); const labelFor = session => abbreviate ? this._chipLabels.get(session.sessionId) : projectName(session.cwd); const busy = sessions.some(s => s.state !== 'idle'); this.visible = sessions.length > 0 && !(hideWhenIdle && !busy); // Age rides on the first chip only. Sessions are sorted by urgency, so // that is the one whose age decides anything; five ages side by side // would just be a wide row of numbers. const ageOnFirst = showAge && busy; const signature = sessions .map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`) .join('|') + `|${ageOnFirst}`; if (signature !== this._chipSignature) { this._chipBox.destroy_all_children(); this._ageLabel = null; sessions.forEach((session, i) => { const { chip, age } = this._buildChip( session, labelFor(session), ageOnFirst && i === 0); if (age) this._ageLabel = { age, session }; this._chipBox.add_child(chip); }); this._chipSignature = signature; } if (this._ageLabel) this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session)); } _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) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`) .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 chip = this._chipLabels?.get(session.sessionId); const title = new St.Label({ text: chip ? `${chip} ${projectName(session.cwd)}` : 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 -------------------------------------------------------- // ---- 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(); } });