Compare commits

..
2 Commits
Author SHA1 Message Date
av be69bec17f 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.
2026-08-09 21:56:10 +03:00
av 67d7b14cf5 Keep headless runs out of the panel
`claude -p` was showing up as a session. It prints one answer and exits:
there is no input line, it cannot be blocked on you, and there is nowhere
to walk over to. A script that runs a few dozen of them turned the panel
into a flicker of chips that were gone before they could be read. The Agent
SDK and editor integrations drive claude the same way and are covered by
the same rule.

The flag is looked for in the arguments of the already-identified claude
process, by exact token, so nothing new has to be discovered -- the pid was
resolved on every event anyway. That resolution moved from apply_event up
into main, which is where the decision has to be made: a headless run is
dropped before the state file is touched at all, so it never creates one
and correspondingly never deletes one on SessionEnd. Its events still reach
the debug log, otherwise "why is my session missing from the panel" would
have nothing to answer with.

Arguments are now read by splitting /proc/<pid>/cmdline on its NUL
separators rather than on spaces. A prompt is an ordinary argument, and
`claude "when do I need -p"` is an interactive session that keeps its chip;
the old space-joined string could not tell the two apart. looks_like_claude
takes the list too, which is what it always wanted -- it was splitting the
joined string back apart itself.

Verified end to end as well as in the classifier: a fake claude runs the
hook as a child through /proc, with -p leaving no file and without -p
leaving one. A real `claude -p` against the installed hook added nothing to
the state directory.
2026-08-09 21:35:04 +03:00
10 changed files with 379 additions and 71 deletions
+58 -7
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» читалось, а не угадывалось.
@@ -137,8 +169,9 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
с выходом, убивается раньше, чем успевает записать: у `claude -p` это наблюдалось
как сессия, навсегда застрявшая в `busy`.
с выходом, убивается раньше, чем успевает записать. Наблюдалось это на `claude -p`
— теперь такие запуски вообще не отслеживаются (см. ниже), но гонка та же самая у
любой сессии, закрытой сразу после ответа, и стоила бы навсегда застрявшего `busy`.
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
@@ -146,6 +179,24 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
устаревшее решение после него.
## Неинтерактивные запуски
`claude -p``--print`) в панель не попадает. Такой запуск печатает один ответ
и завершается: строки ввода у него нет, заблокироваться на вас он не может, идти
к нему некуда. Скрипт из пары десятков таких вызовов превращал бы панель в
мельтешение чипов, исчезающих раньше, чем их успеешь прочесть; так же ведут себя
Agent SDK и интеграции с редакторами.
Флаг ищется в аргументах опознанного процесса claude, по точному совпадению
токена. Аргументы читаются из `/proc/<pid>/cmdline` по разделителю `\0`, а не
разбиением по пробелам: промпт — обычный аргумент, и `claude "когда нужен -p"`
это интерактивная сессия, которая свой чип сохраняет.
Отбрасывание происходит до всякой работы с файлом состояния — headless-сессия не
создаёт его и, соответственно, ничего не удаляет на `SessionEnd`. В отладочный
лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели»
было бы нечем объяснить.
## Сабагенты
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
+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();
}
}
+59 -24
View File
@@ -53,6 +53,10 @@ NOTIFICATION_STATES = {
# inflate the count with TaskCreate, TaskUpdate and the like.
AGENT_TOOLS = {"Agent", "Task"}
# Flags that mean "run one prompt and exit" rather than "open a session". See
# is_headless: such a run has no terminal to be called over to.
HEADLESS_FLAGS = {"-p", "--print"}
# Events that mean the question has been dealt with. Anything else leaves a
# "blocked" session blocked: a subagent finishing, or the next tool starting,
# says nothing about the prompt still sitting on your screen, and clearing it
@@ -169,12 +173,23 @@ def read_environ(pid):
return env
def read_cmdline(pid):
def read_argv(pid):
"""Command line of a process as a list of arguments, empty if it is gone.
Split on the NUL separators the kernel actually puts there, not on spaces:
an argument may contain spaces of its own, and `claude "when to use -p"`
must not read as an argument list containing a bare "-p".
"""
try:
with open("/proc/%d/cmdline" % pid, "rb") as fh:
return fh.read().replace(b"\0", b" ").decode("utf-8", "replace")
raw = fh.read()
except OSError:
return ""
return []
return [arg.decode("utf-8", "replace") for arg in raw.split(b"\0") if arg]
def read_cmdline(pid):
return " ".join(read_argv(pid))
def parent_of(pid):
@@ -188,18 +203,16 @@ def parent_of(pid):
return 0
def looks_like_claude(cmdline):
"""Is this command line the claude binary itself?
def looks_like_claude(argv):
"""Is this argument list the claude binary itself?
Matched per argument, never against the raw string. The hook is spawned as
`/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
Matched per argument, never against the joined string. The hook is spawned
as `/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
contains the word "claude" -- in a path -- without being claude at all.
Latching onto that shell records a pid that exits milliseconds later, and
the session then flickers in and out of the panel.
"""
for token in cmdline.split(" "):
if not token:
continue
for token in argv:
base = os.path.basename(token)
if base == "claude":
return True
@@ -209,12 +222,28 @@ def looks_like_claude(cmdline):
return False
def find_claude_pid():
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
def is_headless(argv):
"""Was this claude started to print one answer and exit?
The hook is spawned through a shell, so the immediate parent is not claude.
Walking beyond a handful of levels risks latching onto an outer claude when
one session drives another, so the search stops early.
`claude -p` has no input line and nobody sitting in front of it, so it can
never be blocked on you and there is nothing to walk over to. Left in, a
script that runs a few dozen of them turns the panel into a flicker of chips
that are gone before they can be read -- and the same goes for the Agent SDK
and editor integrations, which drive claude the same way.
Only exact tokens count. A prompt is an ordinary argument, and `claude "what
does -p do"` is an interactive session that must keep its chip.
"""
return any(token in HEADLESS_FLAGS for token in argv)
def find_claude():
"""Nearest ancestor that is the claude process itself, with its arguments.
Returns (pid, argv), or (0, []) if it cannot be identified. The hook is
spawned through a shell, so the immediate parent is not claude. Walking
beyond a handful of levels risks latching onto an outer claude when one
session drives another, so the search stops early.
Returning 0 rather than guessing matters: the reader deletes state files
whose process is gone, and the obvious fallback -- the shell that spawned
@@ -224,10 +253,11 @@ def find_claude_pid():
for _ in range(6):
if pid <= 1:
break
if looks_like_claude(read_cmdline(pid)):
return pid
argv = read_argv(pid)
if looks_like_claude(argv):
return pid, argv
pid = parent_of(pid)
return 0
return 0, []
def pid_start_time(pid):
@@ -324,7 +354,7 @@ def open_lock(path):
raise
def apply_event(event, state, path, now):
def apply_event(event, state, path, now, claude_pid):
"""Read the current state, decide, and write. Must run under the lock."""
if event.get("hook_event_name") == "SessionStart":
# Swept before any early return: a resumed session keeps its id, so its
@@ -416,13 +446,11 @@ def apply_event(event, state, path, now):
state = "blocked"
message = previous.get("message", "")
# Resolved before the unchanged-check, not after, so that a pid which has
# changed forces a write. A session resumed under a new pid, or one whose
# The pid is resolved by the caller and compared in the unchanged-check
# below, not merely stored. A session resumed under a new pid, or one whose
# pid was recorded wrongly, would otherwise keep the stale value for as
# long as its state happens not to change -- and the reader, finding that
# process gone, would drop a perfectly live session from the panel.
claude_pid = find_claude_pid()
if previous:
# Auto-compaction raises SessionStart again, in the middle of a turn the
# session is still working on. Taking it at face value would flip a busy
@@ -485,6 +513,13 @@ def main():
if state is None:
return 0
# After the debug log, so that a session missing from the panel can still be
# explained by the log, and before the state file is touched at all: a
# headless run must not even delete on SessionEnd, since it never wrote.
claude_pid, claude_argv = find_claude()
if is_headless(claude_argv):
return 0
os.makedirs(STATE_DIR, exist_ok=True)
path = os.path.join(STATE_DIR, "%s.json" % session_id)
@@ -498,7 +533,7 @@ def main():
return 0
with lock:
fcntl.flock(lock, fcntl.LOCK_EX)
apply_event(event, state, path, now)
apply_event(event, state, path, now, claude_pid)
return 0
+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);
+38 -9
View File
@@ -41,22 +41,51 @@ import importlib.util, sys
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
cases = [
("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False),
("/home/u/.local/bin/claude --resume ", True),
("bash /home/u/bin/claude ", True),
("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True),
("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False),
("nvim /home/u/.claude/settings.json ", False),
(["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False),
(["/home/u/.local/bin/claude", "--resume"], True),
(["bash", "/home/u/bin/claude"], True),
(["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True),
(["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False),
(["nvim", "/home/u/.claude/settings.json"], False),
]
bad = 0
for cmd, want in cases:
got = h.looks_like_claude(cmd)
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got))
for argv, want in cases:
got = h.looks_like_claude(argv)
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (" ".join(argv)[:46], got))
bad += got != want
# A one-shot run has no input line and no human in front of it. The prompt is
# an ordinary argument, so a prompt that merely mentions -p must not count.
headless = [
(["claude"], False),
(["claude", "--resume"], False),
(["claude", "-p", "summarise this"], True),
(["claude", "--print", "--output-format", "stream-json"], True),
(["claude", "explain what -p does"], False),
(["claude", "--permission-mode", "plan"], False),
]
for argv, want in headless:
got = h.is_headless(argv)
print(("ok " if got == want else "FAIL ") + "headless %r -> %s" % (" ".join(argv)[:46], got))
bad += got != want
sys.exit(1 if bad else 0)
PY
check "command lines classified correctly" "0" "$?"
# End to end, through /proc rather than through the classifier: a fake "claude"
# runs the hook as a child, exactly as the real one does.
FAKE="$XDG_STATE_HOME/claude"
printf '#!/bin/sh\n"$1"\n' > "$FAKE"
chmod +x "$FAKE"
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
| "$FAKE" "$HOOK" -p
[ -e "$DIR/headless.json" ]; check "claude -p leaves no state file" "1" "$?"
printf '{"session_id":"headless","hook_event_name":"SessionStart","cwd":"/tmp/p"}' \
| "$FAKE" "$HOOK"
[ -e "$DIR/headless.json" ]; check "the same session without -p is recorded" "0" "$?"
rm -f "$DIR/headless.json" "$DIR/headless.json.lock"
# --- state machine ---------------------------------------------------------
emit "$(ev SessionStart '"source":"startup"')"
check "SessionStart -> waiting" "waiting" "$(field state)"
+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');