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.
This commit is contained in:
av
2026-08-09 21:56:10 +03:00
parent 67d7b14cf5
commit be69bec17f
8 changed files with 261 additions and 36 deletions
+37 -5
View File
@@ -42,19 +42,43 @@ Code ждёт меня прямо сейчас?**
текстом `«Claude needs your permission»`. Различить их можно было бы только через
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель
живёт в центральном боксе рядом с часами, и без предела достаточно открытых
сессий сдвинули бы часы с центра.
Чипов показывается три (настраивается), остальные сворачиваются в `+N`. По
умолчанию ряд живёт в центральном боксе сразу справа от часов, и без предела
достаточно открытых сессий сдвинули бы часы с центра.
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в
состоянии показывается только у первого чипа: пять счётчиков рядом — это
ряд чисел, а не ответ на вопрос.
## Место в панели
Бокс (левый, центральный, правый) и позиция внутри бокса задаются в настройках и
применяются сразу, без релогина. Индекс 0 — первым в боксе; в центральном 1 —
сразу справа от часов, единственного его обитателя по умолчанию. Индекс больше
числа элементов кладёт ряд в конец, так что «10» — это способ сказать «последним».
Перестановка **пересоздаёт** индикатор, а не двигает актор: `addToStatusArea`
это и есть регистрация под uuid, а публичного вызова «перенести в другой бокс» в
shell нет; всё остальное лезет в приватные боксы `Main.panel`. Стоит это одного
перечитывания нескольких маленьких файлов состояния, и только когда настройку
трогают.
Здесь же вскрылась давняя утечка. `PanelMenu.ButtonBox` в своём `_init` делает
`this.connect('destroy', this._onDestroy.bind(this))`, а его `_onDestroy`
уничтожает `container` — тот самый `St.Bin`, который лежит в боксе панели.
Имя разрешается по цепочке прототипов, поэтому наш метод с тем же именем **молча
подменял** шелловский, и контейнер оставался в панели после каждого выключения
расширения. Наш обработчик теперь называется `_teardown`. Измерено: до
переименования центральный бокс рос на один пустой актор с каждой перестановкой
(2 → 3 → 4 → 5), после — стабильно 2.
## Метки чипов
Имя проекта сжимается до трёх знаков: если в имени несколько сегментов
(`-`, `_`, camelCase) — инициалы, иначе первые буквы.
Имя проекта сжимается до трёх знаков — в настройках можно больше, но не меньше:
три хватает, чтобы развести инициалы, и достаточно узко, чтобы ряд не толкал
часы. Если в имени несколько сегментов (`-`, `_`, camelCase) — инициалы, иначе
первые буквы.
| Проект | Чип |
|---|---|
@@ -80,6 +104,14 @@ Code ждёт меня прямо сейчас?**
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
рукой, хуже метки с цифрой, которая уже не выглядит нужной.
Более широкая метка берёт **больше инициалов**, а не более длинный префикс — по
той же причине. Поэтому `dev-skills` останется `ds` при любой ширине, а
`claude-code-gnome-extension` при четырёх знаках станет `ccge`.
Ширина — единственное, что сбрасывает закреплённые метки: перерисовка, о которой
попросили сами, это не метка, уехавшая под рукой. Смена ширины перелейблит все
сессии разом.
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
dev-skills» читалось, а не угадывалось.
+40 -6
View File
@@ -5,16 +5,50 @@ import { ClaudeStatusIndicator } from './lib/indicator.js';
export default class ClaudeCodeStatusExtension extends Extension {
enable() {
this._indicator = new ClaudeStatusIndicator(this);
// Centre box, index 1: immediately right of the clock, which is the
// centre box's only occupant by default. The status area on the right
// is where you look for the system's own state; sessions belong next
// to the thing you already glance at.
Main.panel.addToStatusArea(this.uuid, this._indicator, 1, 'center');
this._settings = this.getSettings();
this._place();
// Placement is applied by rebuilding rather than by moving the actor.
// addToStatusArea is what registers the indicator under this uuid and
// there is no documented call to move one between panel boxes; the
// alternatives all reach into Main.panel's private boxes. Rebuilding
// costs one re-read of a handful of small state files, and only when
// the setting is touched.
this._placementId = this._settings.connect('changed::panel-box',
() => this._replace());
this._positionId = this._settings.connect('changed::panel-position',
() => this._replace());
}
disable() {
for (const id of [this._placementId, this._positionId]) {
if (id)
this._settings.disconnect(id);
}
this._placementId = 0;
this._positionId = 0;
this._indicator?.destroy();
this._indicator = null;
this._settings = null;
}
_place() {
this._indicator = new ClaudeStatusIndicator(this);
// Centre box, index 1 by default: immediately right of the clock,
// which is the centre box's only occupant. The status area on the
// right is where you look for the system's own state; sessions belong
// next to the thing you already glance at. An index past the end of
// the box lands at the end, so a large one is a way of saying "last".
Main.panel.addToStatusArea(this.uuid, this._indicator,
this._settings.get_int('panel-position'),
this._settings.get_string('panel-box'));
}
_replace() {
// The indicator's own destroy handler is what unregisters it from the
// status area, so this must happen before the next addToStatusArea --
// that call throws on a uuid that is still registered.
this._indicator?.destroy();
this._indicator = null;
this._place();
}
}
+27 -11
View File
@@ -1,4 +1,4 @@
// Three-character chip labels for the panel.
// Short chip labels for the panel: three characters by default, settable up.
//
// A chip per session only pays off if the label stays put. Two rules do that:
// labels are assigned oldest-session-first, so a session starting now takes the
@@ -9,7 +9,17 @@
//
// Imports nothing, so it runs under plain node or gjs.
const MAX = 3;
// Both entry points take a width, and both default to this: the module is
// imported by tests and by the indicator alike, and a caller that forgets the
// setting should get the documented default rather than a stray one.
const DEFAULT_WIDTH = 3;
/** Widths arrive from GSettings and from tests. One character is the floor:
* at zero every label would be empty and the collision loop would not end. */
function usable(width) {
const n = Math.trunc(Number(width));
return Number.isFinite(n) && n >= 1 ? n : DEFAULT_WIDTH;
}
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
function segments(name) {
@@ -22,29 +32,35 @@ function segments(name) {
.filter(Boolean);
}
/** Up to three characters for a project name.
/** Up to `width` characters for a project name.
*
* Initials for multi-segment names, first letters for single words. Initials
* matter more than they look: a plain prefix collapses "dev-skills" and
* "dev-conventions" onto the same "dev", which is the exact case this has to
* keep apart.
* keep apart. A wider label takes more initials, not a longer prefix, for the
* same reason.
*/
export function abbreviate(name) {
export function abbreviate(name, width = DEFAULT_WIDTH) {
const parts = segments(String(name ?? ''));
if (!parts.length)
return '?';
const raw = parts.length > 1
? parts.map(p => p[0]).join('')
: parts[0];
return raw.slice(0, MAX).toLowerCase();
return raw.slice(0, usable(width)).toLowerCase();
}
/** Assign a label to every session, reusing the ones already handed out.
*
* `previous` is the mapping from the last run; pass the returned map back in.
* Sessions absent from `sessions` drop out, which frees their label for reuse.
*
* `width` applies to labels handed out now. Kept labels are kept whatever
* width they were cut at -- stickiness outranks it, and the caller that
* changes the width is the one that has to drop the old map.
*/
export function assignChips(sessions, previous = new Map()) {
export function assignChips(sessions, previous = new Map(), width = DEFAULT_WIDTH) {
const max = usable(width);
const labels = new Map();
const taken = new Set();
@@ -61,13 +77,13 @@ export function assignChips(sessions, previous = new Map()) {
.sort((a, b) => (a.since || 0) - (b.since || 0));
for (const session of fresh) {
const base = abbreviate(session.base);
const base = abbreviate(session.base, max);
let label = base;
// Digits eat into the base rather than extending past three characters,
// so every chip stays the same width and the row does not ripple.
// Digits eat into the base rather than extending past the width, so
// every chip stays the same size and the row does not ripple.
for (let n = 2; taken.has(label); n++) {
const suffix = String(n);
label = base.slice(0, Math.max(1, MAX - suffix.length)) + suffix;
label = base.slice(0, Math.max(1, max - suffix.length)) + suffix;
}
labels.set(session.sessionId, label);
taken.add(label);
+23 -5
View File
@@ -73,7 +73,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// 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.
this.connect('destroy', () => this._onDestroy());
//
// 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();
}
@@ -81,8 +88,8 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
_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.
// 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,
@@ -176,6 +183,17 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
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 => ({
@@ -186,7 +204,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
since: s.started || s.since,
base: projectName(s.cwd),
})),
this._chipLabels);
this._chipLabels, width);
const labelFor = session => abbreviate
? this._chipLabels.get(session.sessionId)
@@ -398,7 +416,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
// ---- Teardown -------------------------------------------------------
_onDestroy() {
_teardown() {
if (this._destroyed)
return;
this._destroyed = true;
+51 -2
View File
@@ -15,15 +15,38 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
});
window.add(page);
const dispGroup = new Adw.PreferencesGroup({ title: _('Panel') });
const placeGroup = new Adw.PreferencesGroup({
title: _('Placement'),
description: _('Where the row of chips sits in the top bar. Applied at once — no need to reload.'),
});
page.add(placeGroup);
placeGroup.add(this._comboRow(settings, 'panel-box',
_('Panel box'),
_('The centre box holds the clock; the right one is the system status area.'),
[
{ value: 'left', label: _('Left') },
{ value: 'center', label: _('Centre') },
{ value: 'right', label: _('Right') },
]));
placeGroup.add(this._spinRow(settings, 'panel-position',
_('Position in that box'),
_('0 is first. In the centre box, 1 puts the chips just right of the clock. Past the end means last.'),
0, 10));
const dispGroup = new Adw.PreferencesGroup({ title: _('Chips') });
page.add(dispGroup);
dispGroup.add(this._switchRow(settings, 'show-project-name',
_('Label chips with the project'),
_('Name the sessions, not just their states.')));
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
_('Shorten names to three characters'),
_('Shorten names'),
_('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.')));
dispGroup.add(this._spinRow(settings, 'abbrev-length',
_('Label length'),
_('Characters a shortened label may use. Three keeps the row narrow; longer reads more like the name. Changing it relabels every session at once.'),
3, 10));
dispGroup.add(this._spinRow(settings, 'max-chips',
_('Chips shown'),
_('The most urgent sessions get a chip; the rest are counted as “+N”.'),
@@ -118,6 +141,32 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
return row;
}
/** A string key with a fixed set of values.
*
* 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. The write direction is the only one wired
* up -- while this window is open, this row is the only thing that writes
* the key, and it is read afresh every time the window is built.
*/
_comboRow(settings, key, title, subtitle, options) {
const row = new Adw.ComboRow({
title, subtitle,
model: Gtk.StringList.new(options.map(o => o.label)),
});
const values = options.map(o => o.value);
const current = values.indexOf(settings.get_string(key));
// Set before connecting, so restoring the stored value is not itself
// taken for a change the person made.
row.selected = current < 0 ? 0 : current;
row.connect('notify::selected', () => {
const value = values[row.selected];
if (value)
settings.set_string(key, value);
});
return row;
}
_switchRow(settings, key, title, subtitle) {
const row = new Adw.SwitchRow({ title, subtitle });
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
@@ -2,6 +2,22 @@
<schemalist>
<schema id="org.gnome.shell.extensions.claude-code-status"
path="/org/gnome/shell/extensions/claude-code-status/">
<key name="panel-box" type="s">
<choices>
<choice value="left"/>
<choice value="center"/>
<choice value="right"/>
</choices>
<default>'center'</default>
<summary>Which panel box the chips live in</summary>
<description>The centre box holds the clock and is the thing you already glance at, which is why the chips start there. The right box is the system's own state area; the left one sits after the activities button and the app menu.</description>
</key>
<key name="panel-position" type="i">
<default>1</default>
<range min="0" max="10"/>
<summary>Index within that box</summary>
<description>0 puts the chips first, before everything else in the box; the default of 1 puts them immediately right of the clock, the centre box's only other occupant. An index past the end of the box lands at the end.</description>
</key>
<key name="show-project-name" type="b">
<default>true</default>
<summary>Label each chip with its project</summary>
@@ -9,9 +25,15 @@
</key>
<key name="abbreviate-names" type="b">
<default>true</default>
<summary>Shorten project names to three characters</summary>
<summary>Shorten project names on the chips</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="abbrev-length" type="i">
<default>3</default>
<range min="3" max="10"/>
<summary>How many characters a shortened label may use</summary>
<description>Three is enough to tell initials apart and narrow enough that a row of chips does not push the clock about. Longer labels read more like the project name; a disambiguating digit still eats into the label rather than extending past this width, so every chip stays the same size. Below three, distinct projects start sharing a label.</description>
</key>
<key name="max-chips" type="i">
<default>3</default>
<range min="1" max="12"/>
+26
View File
@@ -26,6 +26,17 @@ check('camelCase counts as segments', 'om', abbreviate('outlineMcp'));
check('digits survive', 'p2', abbreviate('proj_2'));
check('empty name does not crash', '?', abbreviate(''));
// --- a wider label ---------------------------------------------------------
// More initials, not a longer prefix: the whole point of initials is keeping
// "dev-skills" and "dev-conventions" apart, and a prefix at any width does not.
check('a wider label takes more initials', 'ccge',
abbreviate('claude-code-gnome-extension', 4));
check('and stops at the segments it has', 'ds', abbreviate('dev-skills', 6));
check('a single word gets more of itself', 'jellyb', abbreviate('jellybit', 6));
check('the default is still three', 'ccg', abbreviate('claude-code-gnome-extension'));
check('a nonsense width falls back to the default', 'ccg',
abbreviate('claude-code-gnome-extension', 'wide'));
// --- collisions ------------------------------------------------------------
const two = [
{ sessionId: 'a', since: 100, base: 'dev-skills' },
@@ -70,6 +81,21 @@ check('every label fits in three characters', [3], lengths);
check('twelve sessions in one project are all distinct',
12, new Set(wide.values()).size);
// A digit still eats into the label instead of extending past the width, which
// is what keeps a row of chips from rippling when one of them gains a digit.
const wider = assignChips(many, new Map(), 5);
check('a wider run holds its own width',
[5], [...new Set([...wider.values()].map(l => l.length))]);
check('and stays distinct', 12, new Set(wider.values()).size);
check('the oldest keeps the clean label', 'umbar', wider.get('s0'));
check('the next one gives up a character', 'umba2', wider.get('s1'));
// Stickiness outranks the width: labels already handed out are kept as they
// are. The indicator drops the map when the setting changes, which is the only
// way a label is allowed to move.
const kept = assignChips(many, wide, 5);
check('an existing label is not re-cut', 'umb', kept.get('s0'));
out(failures ? `\n${failures} failure(s)` : '\nall passed');
if (typeof imports !== 'undefined')
imports.system.exit(failures ? 1 : 0);
+34 -6
View File
@@ -36,10 +36,13 @@ const schemas = Gio.SettingsSchemaSource.new_from_directory(
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
const prefs = new Prefs();
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
prefs.getSettings = () => new Gio.Settings({
settings_schema: schemas.lookup(
'org.gnome.shell.extensions.claude-code-status', true),
});
// A memory backend, not the default one: the test writes a key to check the
// hand-rolled combo binding, and it has no business touching the settings of
// whoever is running it.
const backend = Gio.memory_settings_backend_new();
prefs.getSettings = () => Gio.Settings.new_full(
schemas.lookup('org.gnome.shell.extensions.claude-code-status', true),
backend, '/org/gnome/shell/extensions/claude-code-status/');
const window = new Adw.PreferencesWindow();
prefs.fillPreferencesWindow(window);
@@ -48,7 +51,8 @@ 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 === 'AdwSpinRow' || type === 'AdwActionRow')
if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' ||
type === 'AdwComboRow' || type === 'AdwActionRow')
rows.push({ type, title: c.title, subtitle: c.subtitle });
walk(c);
}
@@ -70,9 +74,33 @@ for (const row of rows)
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
.list_keys().length;
const controls = rows.filter(
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length;
r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow' ||
r.type === 'AdwComboRow').length;
check('a control for every settings key', controls === keys, `${controls} of ${keys}`);
// The combo is bound by hand rather than through Gio.Settings.bind, so the
// binding is worth a test: it must start on the stored value and write back
// the value, not the row index.
const combo = [];
const findCombos = widget => {
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
if (c.constructor.$gtype.name === 'AdwComboRow')
combo.push(c);
findCombos(c);
}
};
findCombos(window);
const settings = prefs.getSettings();
check('the panel box row exists', combo.length === 1, `${combo.length} combo rows`);
if (combo.length) {
check('starts on the stored value',
combo[0].selected === 1, `selected ${combo[0].selected}`); // 'center'
combo[0].selected = 2;
check('writing back stores the value, not the index',
settings.get_string('panel-box') === 'right',
settings.get_string('panel-box'));
}
// 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.
const status = rows.find(r => r.title === 'Status');