// 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 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 { drawState } from './glyph.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'); default: return state; } } /** Paint a state glyph in the panel's own text colour. * * Nothing here picks a colour: the foreground comes from the theme node, so * the row follows the panel through light, dark and custom themes. The shapes * live in glyph.js, which imports nothing and can therefore be rendered to a * file and inspected. */ function drawStateDot(area, state) { const cr = area.get_context(); try { const [w, h] = area.get_surface_size(); const c = area.get_theme_node().get_foreground_color(); // Normalised by inspection rather than by assumption: the colour struct // behind this changed between shell versions, and a wrong guess either // way paints the glyph invisible or fully saturated. const scale = Math.max(c.red, c.green, c.blue, c.alpha) > 1 ? 255 : 1; drawState(cr, state, w, h, { r: c.red / scale, g: c.green / scale, b: c.blue / scale, a: c.alpha / scale, }); } 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()); // Connected, not overridden: clutter_actor_destroy is not a vfunc, so a // destroy() override only runs when JS calls it. An actor torn down any // other way -- another extension rebuilding the panel boxes -- would // leave the timer and the file monitor running against a disposed // actor, screaming into the log every 20 seconds. // // Not named _onDestroy, which is the name PanelMenu.ButtonBox gives its // own handler. It connects `this._onDestroy.bind(this)` in _init, and // that resolves through the prototype chain -- so a subclass method of // that name silently replaces it, and the St.Bin the panel box actually // holds is never destroyed. Measured: an empty container stayed behind // in the box on every teardown. this.connect('destroy', () => this._teardown()); 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. // Where the row sits in the panel is a setting; extension.js places it. 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, }); // The panel is monochrome, so shape alone carries the state: // disc inside a ring — asking you something; // filled disc — input line free, your move; // ring — working. // Colour is left to the shell's own accents; an indicator that paints // its own red competes with them and stops matching the theme. const dot = new St.DrawingArea({ style_class: 'ccs-dot', y_align: Clutter.ActorAlign.CENTER, }); // Size comes from the stylesheet so St scales it: setting it here would // pin the glyph to physical pixels and halve it on a HiDPI display. dot.connect('repaint', area => drawStateDot(area, session.state)); chip.add_child(dot); let age = null; if (this._settings.get_boolean('show-project-name')) { const text = new St.Label({ style_class: 'ccs-chip-label', y_align: Clutter.ActorAlign.CENTER, text: label, }); // With shortening off the label is a whole project name, which can // be arbitrarily long: an unbounded label in the panel pushes the // clock aside and, past a point, hands Pango a width it cannot // represent. text.clutter_text.ellipsize = Pango.EllipsizeMode.END; chip.add_child(text); } 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 showAge = this._settings.get_boolean('show-age'); const abbreviate = this._settings.get_boolean('abbreviate-names'); const maxChips = this._settings.get_int('max-chips'); const width = this._settings.get_int('abbrev-length'); // Labels are sticky by design, which here works against the setting: // widening would leave every session on screen at its old width until // it ended. Changing the width is the one thing that discards the map // -- a relabelling the person asked for is not a label moving under // their hand. if (width !== this._chipWidth) { this._chipLabels = new Map(); this._chipWidth = width; } this._chipLabels = assignChips( sessions.map(s => ({ sessionId: s.sessionId, // Session age, not time in the current state: seniority decides // who keeps the clean label, and a session that changed state a // second ago has not thereby become the youngest. since: s.started || s.since, base: projectName(s.cwd), })), this._chipLabels, width); const labelFor = session => abbreviate ? this._chipLabels.get(session.sessionId) : projectName(session.cwd); // Nothing running means nothing to say; the button disappears rather // than sitting there empty. this.visible = sessions.length > 0; // Chips are ordered by urgency, so cutting the tail keeps the ones that // need you soonest. Without a cap the row grows without bound, and it // sits in the centre box -- enough sessions would shove the clock off // centre. Labels are still assigned over every session, so the menu and // the panel agree and a chip does not change when the cap does. const shown = sessions.slice(0, maxChips); const hidden = sessions.length - shown.length; // 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 && shown.length > 0; const signature = shown .map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`) .join('|') + `|${ageOnFirst}|${hidden}`; if (signature !== this._chipSignature) { this._chipBox.destroy_all_children(); this._ageLabel = null; shown.forEach((session, i) => { const { chip, age } = this._buildChip( session, labelFor(session), ageOnFirst && i === 0); if (age) this._ageLabel = { age, sessionId: session.sessionId }; this._chipBox.add_child(chip); }); if (hidden > 0) { this._chipBox.add_child(new St.Label({ style_class: 'ccs-overflow', y_align: Clutter.ActorAlign.CENTER, text: `+${hidden}`, })); } this._chipSignature = signature; } if (this._ageLabel) { // Looked up again rather than captured: a session that returns to // the same state within one refresh keeps the signature unchanged, // and a captured object would then show an age that stopped moving. const current = sessions.find(s => s.sessionId === this._ageLabel.sessionId); if (current) this._ageLabel.age.text = formatAge(this._ageOf(current)); } } _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. // The tab name is not in the signature: it only feeds the subtitle, // which is refreshed in place below. const signature = sessions .map(s => `${s.sessionId}:${s.state}:${this._chipLabels?.get(s.sessionId) ?? ''}`) .join('|'); if (signature !== this._rowSignature) { this._rebuildRows(sessions); this._rowSignature = signature; } const byId = new Map(sessions.map(s => [s.sessionId, s])); for (const row of this._rows) { const session = byId.get(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) { // The menu reports; it does not act. Rows are built inert rather than // switched off afterwards, because PopupBaseMenuItem latches // _activatable in its constructor and a row demoted later keeps the // styling of a clickable one. const item = new PopupMenu.PopupBaseMenuItem( { 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, }); title.clutter_text.ellipsize = Pango.EllipsizeMode.END; 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); this._rows.push({ sessionId: session.sessionId, age, subtitle }); return item; } /** Second line: what the session needs, then where to find it. */ _subtitleFor(session) { const what = [stateLabel(session.state)]; // A batch is why a session can be working with nothing on its own // plate, and how far along it is decides whether to wait for it. if (session.agents > 0) { what.push(session.agents === 1 ? _('1 subagent') : `${session.agents} ${_('subagents')}`); } 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)); // The message that comes with a permission prompt is generic -- // observed verbatim as "Claude needs your permission", with no tool // name and no command, and identical whether the session is asking to // run something or putting a question to you. Showing it would push // the tab and the path out of the line to say less than the state // label already does. Getting the real command means reading the tail // of the transcript when the prompt fires; it is not in the event. return `${what.join(' · ')} · ${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')); } // ---- Teardown ------------------------------------------------------- _teardown() { if (this._destroyed) return; 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(); } });