Two changes that pull in the same direction: the panel says less, and the menu stops pretending to do anything. The chip row had no bound. It sits in the centre box next to the clock, so enough open sessions would have shoved the clock off centre. It now shows the first N, settable and three by default, and counts the rest as "+N". Chips are already ordered by urgency, so the ones that survive the cut are the ones that need you soonest. Labels are still assigned across every session, including hidden ones, so a chip does not change when the cap does or when a session ahead of it disappears. Clicking a menu row used to switch the zellij tab and raise a terminal. That is gone. It cost real machinery for what it saved -- gnome-terminal runs every window under one shared server process, so windows cannot be matched by pid and the code fell back to matching the zellij session name against window titles, with all the ways that misses. The menu reports status; alt-tab is not the bottleneck. Rows are built inert rather than demoted after the fact, because PopupBaseMenuItem latches _activatable in its constructor. zellij tab lookup stays: naming the tab is the better half of that feature and costs one process every couple of minutes. The preferences test now asserts a control per settings key rather than a switch per key, so the new spin row counts and a future non-boolean setting cannot slip in without one.
390 lines
14 KiB
JavaScript
390 lines
14 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();
|
|
drawState(cr, state, w, h, {
|
|
r: c.red / 255, g: c.green / 255, b: c.blue / 255, a: c.alpha / 255,
|
|
});
|
|
} 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,
|
|
});
|
|
|
|
// 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,
|
|
});
|
|
dot.set_width(14);
|
|
dot.set_height(14);
|
|
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 showAge = this._settings.get_boolean('show-age');
|
|
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
|
const maxChips = this._settings.get_int('max-chips');
|
|
|
|
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);
|
|
|
|
// 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, session };
|
|
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)
|
|
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.
|
|
// 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;
|
|
}
|
|
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) {
|
|
// 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,
|
|
});
|
|
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'));
|
|
}
|
|
|
|
// ---- 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();
|
|
}
|
|
});
|