"Idle" was set by SessionStart and by nothing else, and there was no path back into it. So it never meant "sitting unused" -- it meant "opened and never asked anything yet", a state a few seconds long that you would almost never catch. Meanwhile a session that finished an hour ago and was forgotten showed as waiting, which is correct but leaves the fourth state with nothing to describe. A session that has just opened is waiting for your first prompt exactly as one that finished a turn is waiting for your next. They are the same thing, and now they are the same state. Three glyphs instead of four, which also gives the remaining three more room to be told apart in a monochrome panel. Files written by the previous hook still say "idle", and a session open across the upgrade must not disappear, so unrecognised states now read as waiting rather than being treated as unknown. Covered by a test that feeds an "idle" file to the store and asserts it comes back as waiting, sorted by age among the others. The "hide when nothing is running" setting goes with it. Its condition was "no sessions, or all of them idle"; with idle gone the second half is unreachable and the first was already unconditional, so the switch could no longer change anything. A control that does nothing is worse than no control. The compaction test also got stronger in passing: it now checks that a mid-turn SessionStart leaves a *busy* session alone, which is the case that matters. It used to assert from waiting, where the state it was guarding against happened to be the state already stored.
403 lines
15 KiB
JavaScript
403 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 { 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;
|
|
}
|
|
}
|
|
|
|
const TERMINAL_CLASSES = [
|
|
'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty',
|
|
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
|
|
];
|
|
|
|
/** 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');
|
|
|
|
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;
|
|
|
|
// 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 && sessions.length > 0;
|
|
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));
|
|
|
|
// 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 `${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();
|
|
}
|
|
});
|