Files
claude-code-gnome-extension/lib/indicator.js
T
av a4e5d653de Show one chip per session, right of the clock
The panel showed a single aggregate: the most urgent session, plus a "+N"
for the others. That answers "what is the worst thing happening", which
is not the question with five projects open -- "what is each of them
doing" needs each of them on screen.

Each session now gets a chip: state glyph plus a three-character project
label, in the centre box just right of the clock. Time in state stays on
the first chip only; five counters side by side are a row of numbers,
not an answer.

Labels are initials for multi-segment names, first letters otherwise.
Initials rather than a prefix, because a prefix collapses dev-skills and
dev-conventions onto the same "dev" -- exactly the pair that has to stay
apart. Collisions, including two sessions in one project where the cwd
is identical, take a digit: ds, ds2, ds3.

Two rules keep a label still, and the feature is worthless without them.
Assignment runs oldest-session-first, so a session starting now takes
the suffix instead of displacing one already on screen; and a label
belongs to its session until it ends, even after whatever forced the
digit has closed. A label that moves under your hand is worse than one
carrying a digit that no longer looks necessary.

The zellij tab name was considered as the label source and dropped. It
arrives asynchronously from dump-layout, so a sticky label would freeze
whatever the project name produced first and never adopt it. Digits
already separate same-project sessions, so the tab name buys nothing
here and stays where it is useful, in the menu.

Menu rows now lead with the same label, so the mapping from "ds" to
dev-skills is read rather than guessed.
2026-08-09 18:45:44 +03:00

429 lines
15 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 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();
}
});