Files
claude-code-gnome-extension/lib/indicator.js
T
av be69bec17f Make the panel position and the label width settings
Two things that were constants and had no business being constants.

Placement: the box (left, centre, right) and the index within it. The
default is unchanged -- centre, index 1, immediately right of the clock --
and an index past the end of a box lands at the end, so a large one means
"last". Applied at once, no reload.

Moving is done by rebuilding the indicator rather than by moving the actor.
addToStatusArea is what registers it under the uuid and there is no
documented call to move one between boxes; everything else reaches into
Main.panel's private boxes. It costs one re-read of a few small state files
and only when the setting is touched.

Label width: three characters by default, settable up to ten, not down.
Three is enough to keep initials apart and narrow enough not to shove the
clock about; below three, distinct projects start sharing a label. A wider
label takes more initials rather than a longer prefix -- a prefix collapses
dev-skills and dev-conventions at any width -- so dev-skills stays "ds"
however wide the setting, while claude-code-gnome-extension becomes "ccge"
at four. A disambiguating digit still eats into the label instead of
extending past the width, so a chip that gains one does not push the row.

Sticky labels work against that setting: kept labels are kept whatever
width they were cut at, so widening would leave every session on screen at
its old width until it ended. The width is therefore the one thing that
discards the map -- a relabelling that was asked for is not a label moving
under your hand.

The combo row is bound by hand: Gio.Settings.bind maps a boolean to
'active' and an int to 'value', but a string to a selected index needs
bind_with_mapping, which is not introspectable. Only the write direction is
wired up, and the test covers it, because a row that stores its index
instead of its value looks fine until the shell reads the key.

Fixed on the way, found by watching the centre box grow 2 -> 3 -> 4 -> 5
across four moves: PanelMenu.ButtonBox connects `this._onDestroy.bind(this)`
in its _init, and its _onDestroy is what destroys the container -- the
St.Bin the panel box actually holds. The name resolves through the
prototype chain, so this extension's own _onDestroy had been silently
replacing the shell's since the beginning, leaving an empty container in
the panel on every teardown. Renamed to _teardown; the box now stays at two
children across moves and across enable/disable cycles.

Verified in a nested shell on a copy of the extension carrying temporary
logging, since the shell refuses screenshots to non-portal callers: every
box, indices 0, 1 and 9, and widths 3, 5, 8, 10 and back, with no JS errors
and no leftover actors.
2026-08-09 21:56:10 +03:00

435 lines
17 KiB
JavaScript

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