Cap the chips at three, and make the menu report-only

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.
This commit is contained in:
av
2026-08-09 19:37:38 +03:00
parent 97783d43b8
commit ca42d69704
7 changed files with 80 additions and 81 deletions
+15 -12
View File
@@ -42,6 +42,10 @@ Code ждёт меня прямо сейчас?**
текстом `«Claude needs your permission»`. Различить их можно было бы только через
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель
живёт в центральном боксе рядом с часами, и без предела достаточно открытых
сессий сдвинули бы часы с центра.
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в
состоянии показывается только у первого чипа: пять счётчиков рядом — это
@@ -178,13 +182,17 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
## zellij
Если сессии живут в табах zellij, меню показывает **имя таба**, а клик
переключает на него. Поиск идёт через `zellij action dump-layout` — рабочий
каталог сессии сопоставляется с каталогами пейнов, потому что в дампе раскладки
нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится. Отсюда следствия: две
сессии в одном табе неразличимы, а сессия, сменившая `cwd` после открытия пейна,
не найдётся. Строки, которые не разрешились, остаются некликабельными — вместо
того чтобы делать вид, будто клик что-то делает.
Если сессии живут в табах zellij, меню называет **имя таба** — это лучший ответ
на «в какой терминал идти», чем путь. Поиск идёт через `zellij action
dump-layout`: рабочий каталог сессии сопоставляется с каталогами пейнов, потому
что в дампе раскладки нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится.
Отсюда следствия: две сессии в одном табе неразличимы, а сессия, сменившая
`cwd` после открытия пейна, не найдётся.
Меню **ничего не делает** — только показывает. Ни строки, ни чипы не кликабельны:
переключение таба и подъём окна были написаны и убраны, потому что стоили заметной
логики (окна gnome-terminal нельзя сопоставить по pid — все они под одним
серверным процессом) ради экономии одного alt-tab.
Если zellij не используется, выключите в настройках: он стоит одного процесса
раз в пару минут.
@@ -198,11 +206,6 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
путается с «мёртв», — и такие записи истекают по возрасту, через 36 часов.
- **`Stop` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода»,
и решение для вас в обоих случаях одно и то же.
- **Фокус ведёт к табу zellij, а не к окну.** gnome-terminal держит все окна под
одним общим серверным процессом, так что окно нельзя сопоставить по pid. Окно
поднимается, только если в его заголовке есть имя zellij-сессии; когда
совпадения нет, таб всё равно переключается, а фокус остаётся на месте —
поднять произвольный терминал хуже, чем не поднимать никакой.
## Тесты
+28 -50
View File
@@ -6,7 +6,6 @@ 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';
@@ -27,11 +26,6 @@ function stateLabel(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
@@ -164,6 +158,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
_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 => ({
@@ -181,23 +176,38 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// 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 && sessions.length > 0;
const signature = sessions
const ageOnFirst = showAge && shown.length > 0;
const signature = shown
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
.join('|') + `|${ageOnFirst}`;
.join('|') + `|${ageOnFirst}|${hidden}`;
if (signature !== this._chipSignature) {
this._chipBox.destroy_all_children();
this._ageLabel = null;
sessions.forEach((session, i) => {
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;
}
@@ -210,8 +220,10 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// 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._tabFor(s) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
.map(s => `${s.sessionId}:${s.state}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
.join('|');
if (signature !== this._rowSignature) {
this._rebuildRows(sessions);
@@ -259,13 +271,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
}
_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.
// 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(
tab ? {} : { reactive: false, can_focus: false });
{ reactive: false, can_focus: false });
item.add_style_class_name('ccs-row');
const column = new St.BoxLayout({ vertical: true, x_expand: true });
@@ -296,9 +307,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
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;
}
@@ -360,36 +368,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
.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 -------------------------------------------------------
+2 -13
View File
@@ -1,8 +1,8 @@
// Maps a session's working directory to the zellij tab it is running in.
//
// Knowing a session waits for you is only half the answer; the other half is
// where to look. When sessions live in zellij tabs, the tab name is a better
// answer than a path, and zellij can be told to switch to it.
// where to look. When sessions live in zellij tabs, the tab name answers that
// better than a path does.
//
// `zellij action dump-layout` prints tab names with each pane's cwd but no pane
// ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the
@@ -115,17 +115,6 @@ export class ZellijTabs {
this._cache.clear();
this._inFlight.clear();
}
/** Switch the given zellij session to a tab. Fire and forget. */
goToTab(session, tab) {
try {
Gio.Subprocess.new(
['zellij', '--session', session, 'action', 'go-to-tab-name', tab],
Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE);
} catch (e) {
logError(e, 'claude-code-status: zellij go-to-tab-name failed');
}
}
}
/** Extract tab names and their pane working directories from a KDL layout.
+16 -1
View File
@@ -24,6 +24,10 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
_('Shorten names to three characters'),
_('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.')));
dispGroup.add(this._spinRow(settings, 'max-chips',
_('Chips shown'),
_('The most urgent sessions get a chip; the rest are counted as “+N”.'),
1, 12));
dispGroup.add(this._switchRow(settings, 'show-age',
_('Show time in state'),
_('Shown on the most urgent session only.')));
@@ -36,7 +40,7 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
zellijGroup.add(this._switchRow(settings, 'zellij-integration',
_('Resolve tab names'),
_('Show the zellij tab in the menu and switch to it on click. Ignored when zellij is not installed.')));
_('Name the zellij tab each session runs in. Ignored when zellij is not installed.')));
// --- Hooks ---------------------------------------------------------
// The indicator is only as good as the hooks feeding it, and a silent
@@ -98,6 +102,17 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
}
}
_spinRow(settings, key, title, subtitle, lower, upper) {
const row = new Adw.SpinRow({
title, subtitle,
adjustment: new Gtk.Adjustment({
lower, upper, step_increment: 1, page_increment: 1,
}),
});
settings.bind(key, row, 'value', Gio.SettingsBindFlags.DEFAULT);
return row;
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
@@ -12,6 +12,12 @@
<summary>Shorten project names to three characters</summary>
<description>Chips show initials ("dev-skills" becomes "ds") so a row of sessions stays narrow. Sessions that would collide, including two in the same project, get a digit by seniority: the older one keeps its label. Turn off to show full project names.</description>
</key>
<key name="max-chips" type="i">
<default>3</default>
<range min="1" max="12"/>
<summary>How many sessions get a chip</summary>
<description>Chips are ordered by urgency, so the ones shown are the ones that need you soonest; the rest are counted as "+N". Keeps the row from pushing the clock off centre when many sessions are open.</description>
</key>
<key name="show-age" type="b">
<default>true</default>
<summary>Show how long the session has been in this state</summary>
@@ -20,7 +26,7 @@
<key name="zellij-integration" type="b">
<default>true</default>
<summary>Resolve zellij tab names</summary>
<description>Look up which zellij tab each session runs in, show it in the menu, and let a click switch to that tab. Requires the zellij command; harmless when it is absent.</description>
<description>Look up which zellij tab each session runs in and name it in the menu, which answers "which terminal" better than a path does. Requires the zellij command; harmless when it is absent.</description>
</key>
</schema>
</schemalist>
+6
View File
@@ -34,6 +34,12 @@
font-feature-settings: "tnum";
}
.ccs-overflow {
font-size: 0.9em;
opacity: 0.7;
font-feature-settings: "tnum";
}
.ccs-summary {
font-weight: bold;
}
+6 -4
View File
@@ -48,7 +48,7 @@ const rows = [];
const walk = widget => {
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
const type = c.constructor.$gtype.name;
if (type === 'AdwSwitchRow' || type === 'AdwActionRow')
if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' || type === 'AdwActionRow')
rows.push({ type, title: c.title, subtitle: c.subtitle });
walk(c);
}
@@ -65,11 +65,13 @@ function check(name, condition, detail = '') {
for (const row of rows)
print(` ${row.type.replace('Adw', '').padEnd(10)} ${row.title}`);
// One switch per settings key, so a key added without a row is caught.
// One control per settings key, so a key added without a row to change it is
// caught here rather than by a user wondering why nothing happens.
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
.list_keys().length;
const switches = rows.filter(r => r.type === 'AdwSwitchRow').length;
check('a switch for every settings key', switches === keys, `${switches} of ${keys}`);
const controls = rows.filter(
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length;
check('a control for every settings key', controls === keys, `${controls} of ${keys}`);
// The hook status line is the reason this page is worth opening at all: a
// silent panel looks the same whether nothing runs or nothing is installed.