Compare commits
2
Commits
2565d45bb5
...
be69bec17f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be69bec17f
|
||
|
|
67d7b14cf5
|
@@ -42,19 +42,43 @@ Code ждёт меня прямо сейчас?**
|
|||||||
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
текстом `«Claude needs your permission»`. Различить их можно было бы только через
|
||||||
`PreToolUse` — ценой записи файла на каждый вызов инструмента.
|
`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` — это
|
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
|
||||||
dev-skills» читалось, а не угадывалось.
|
dev-skills» читалось, а не угадывалось.
|
||||||
@@ -137,8 +169,9 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
|
|
||||||
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
|
`Stop` и `SessionEnd` зарегистрированы синхронно, в отличие от остальных. Оба
|
||||||
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
|
срабатывают, когда процесс вот-вот затихнет, и асинхронный хук, проигравший гонку
|
||||||
с выходом, убивается раньше, чем успевает записать: у `claude -p` это наблюдалось
|
с выходом, убивается раньше, чем успевает записать. Наблюдалось это на `claude -p`
|
||||||
как сессия, навсегда застрявшая в `busy`.
|
— теперь такие запуски вообще не отслеживаются (см. ниже), но гонка та же самая у
|
||||||
|
любой сессии, закрытой сразу после ответа, и стоила бы навсегда застрявшего `busy`.
|
||||||
|
|
||||||
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
|
Хуки одной сессии выполняются параллельно, поэтому весь цикл «прочитать — решить —
|
||||||
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
|
записать» идёт под `flock` на `<session_id>.json.lock`, а событие старше
|
||||||
@@ -146,6 +179,24 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
|||||||
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
|
всё ещё позволяет хуку, прочитавшему старое состояние до `Stop`, записать своё
|
||||||
устаревшее решение после него.
|
устаревшее решение после него.
|
||||||
|
|
||||||
|
## Неинтерактивные запуски
|
||||||
|
|
||||||
|
`claude -p` (и `--print`) в панель не попадает. Такой запуск печатает один ответ
|
||||||
|
и завершается: строки ввода у него нет, заблокироваться на вас он не может, идти
|
||||||
|
к нему некуда. Скрипт из пары десятков таких вызовов превращал бы панель в
|
||||||
|
мельтешение чипов, исчезающих раньше, чем их успеешь прочесть; так же ведут себя
|
||||||
|
Agent SDK и интеграции с редакторами.
|
||||||
|
|
||||||
|
Флаг ищется в аргументах опознанного процесса claude, по точному совпадению
|
||||||
|
токена. Аргументы читаются из `/proc/<pid>/cmdline` по разделителю `\0`, а не
|
||||||
|
разбиением по пробелам: промпт — обычный аргумент, и `claude "когда нужен -p"` —
|
||||||
|
это интерактивная сессия, которая свой чип сохраняет.
|
||||||
|
|
||||||
|
Отбрасывание происходит до всякой работы с файлом состояния — headless-сессия не
|
||||||
|
создаёт его и, соответственно, ничего не удаляет на `SessionEnd`. В отладочный
|
||||||
|
лог (см. ниже) её события при этом попадают: иначе «почему сессии нет в панели»
|
||||||
|
было бы нечем объяснить.
|
||||||
|
|
||||||
## Сабагенты
|
## Сабагенты
|
||||||
|
|
||||||
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
|
Типичный сценарий: вы просите запустить батч, основной агент разворачивает его и
|
||||||
|
|||||||
+40
-6
@@ -5,16 +5,50 @@ import { ClaudeStatusIndicator } from './lib/indicator.js';
|
|||||||
|
|
||||||
export default class ClaudeCodeStatusExtension extends Extension {
|
export default class ClaudeCodeStatusExtension extends Extension {
|
||||||
enable() {
|
enable() {
|
||||||
this._indicator = new ClaudeStatusIndicator(this);
|
this._settings = this.getSettings();
|
||||||
// Centre box, index 1: immediately right of the clock, which is the
|
this._place();
|
||||||
// centre box's only occupant by default. The status area on the right
|
// Placement is applied by rebuilding rather than by moving the actor.
|
||||||
// is where you look for the system's own state; sessions belong next
|
// addToStatusArea is what registers the indicator under this uuid and
|
||||||
// to the thing you already glance at.
|
// there is no documented call to move one between panel boxes; the
|
||||||
Main.panel.addToStatusArea(this.uuid, this._indicator, 1, 'center');
|
// 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() {
|
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?.destroy();
|
||||||
this._indicator = null;
|
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
@@ -53,6 +53,10 @@ NOTIFICATION_STATES = {
|
|||||||
# inflate the count with TaskCreate, TaskUpdate and the like.
|
# inflate the count with TaskCreate, TaskUpdate and the like.
|
||||||
AGENT_TOOLS = {"Agent", "Task"}
|
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
|
# Events that mean the question has been dealt with. Anything else leaves a
|
||||||
# "blocked" session blocked: a subagent finishing, or the next tool starting,
|
# "blocked" session blocked: a subagent finishing, or the next tool starting,
|
||||||
# says nothing about the prompt still sitting on your screen, and clearing it
|
# says nothing about the prompt still sitting on your screen, and clearing it
|
||||||
@@ -169,12 +173,23 @@ def read_environ(pid):
|
|||||||
return env
|
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:
|
try:
|
||||||
with open("/proc/%d/cmdline" % pid, "rb") as fh:
|
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:
|
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):
|
def parent_of(pid):
|
||||||
@@ -188,18 +203,16 @@ def parent_of(pid):
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def looks_like_claude(cmdline):
|
def looks_like_claude(argv):
|
||||||
"""Is this command line the claude binary itself?
|
"""Is this argument list the claude binary itself?
|
||||||
|
|
||||||
Matched per argument, never against the raw string. The hook is spawned as
|
Matched per argument, never against the joined string. The hook is spawned
|
||||||
`/bin/sh -c /.../claude-status-hook.py`, so its parent's command line
|
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.
|
contains the word "claude" -- in a path -- without being claude at all.
|
||||||
Latching onto that shell records a pid that exits milliseconds later, and
|
Latching onto that shell records a pid that exits milliseconds later, and
|
||||||
the session then flickers in and out of the panel.
|
the session then flickers in and out of the panel.
|
||||||
"""
|
"""
|
||||||
for token in cmdline.split(" "):
|
for token in argv:
|
||||||
if not token:
|
|
||||||
continue
|
|
||||||
base = os.path.basename(token)
|
base = os.path.basename(token)
|
||||||
if base == "claude":
|
if base == "claude":
|
||||||
return True
|
return True
|
||||||
@@ -209,12 +222,28 @@ def looks_like_claude(cmdline):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def find_claude_pid():
|
def is_headless(argv):
|
||||||
"""Nearest ancestor that is the claude process itself, or 0 if unknown.
|
"""Was this claude started to print one answer and exit?
|
||||||
|
|
||||||
The hook is spawned through a shell, so the immediate parent is not claude.
|
`claude -p` has no input line and nobody sitting in front of it, so it can
|
||||||
Walking beyond a handful of levels risks latching onto an outer claude when
|
never be blocked on you and there is nothing to walk over to. Left in, a
|
||||||
one session drives another, so the search stops early.
|
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
|
Returning 0 rather than guessing matters: the reader deletes state files
|
||||||
whose process is gone, and the obvious fallback -- the shell that spawned
|
whose process is gone, and the obvious fallback -- the shell that spawned
|
||||||
@@ -224,10 +253,11 @@ def find_claude_pid():
|
|||||||
for _ in range(6):
|
for _ in range(6):
|
||||||
if pid <= 1:
|
if pid <= 1:
|
||||||
break
|
break
|
||||||
if looks_like_claude(read_cmdline(pid)):
|
argv = read_argv(pid)
|
||||||
return pid
|
if looks_like_claude(argv):
|
||||||
|
return pid, argv
|
||||||
pid = parent_of(pid)
|
pid = parent_of(pid)
|
||||||
return 0
|
return 0, []
|
||||||
|
|
||||||
|
|
||||||
def pid_start_time(pid):
|
def pid_start_time(pid):
|
||||||
@@ -324,7 +354,7 @@ def open_lock(path):
|
|||||||
raise
|
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."""
|
"""Read the current state, decide, and write. Must run under the lock."""
|
||||||
if event.get("hook_event_name") == "SessionStart":
|
if event.get("hook_event_name") == "SessionStart":
|
||||||
# Swept before any early return: a resumed session keeps its id, so its
|
# 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"
|
state = "blocked"
|
||||||
message = previous.get("message", "")
|
message = previous.get("message", "")
|
||||||
|
|
||||||
# Resolved before the unchanged-check, not after, so that a pid which has
|
# The pid is resolved by the caller and compared in the unchanged-check
|
||||||
# changed forces a write. A session resumed under a new pid, or one whose
|
# 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
|
# 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
|
# long as its state happens not to change -- and the reader, finding that
|
||||||
# process gone, would drop a perfectly live session from the panel.
|
# process gone, would drop a perfectly live session from the panel.
|
||||||
claude_pid = find_claude_pid()
|
|
||||||
|
|
||||||
if previous:
|
if previous:
|
||||||
# Auto-compaction raises SessionStart again, in the middle of a turn the
|
# 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
|
# session is still working on. Taking it at face value would flip a busy
|
||||||
@@ -485,6 +513,13 @@ def main():
|
|||||||
if state is None:
|
if state is None:
|
||||||
return 0
|
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)
|
os.makedirs(STATE_DIR, exist_ok=True)
|
||||||
path = os.path.join(STATE_DIR, "%s.json" % session_id)
|
path = os.path.join(STATE_DIR, "%s.json" % session_id)
|
||||||
|
|
||||||
@@ -498,7 +533,7 @@ def main():
|
|||||||
return 0
|
return 0
|
||||||
with lock:
|
with lock:
|
||||||
fcntl.flock(lock, fcntl.LOCK_EX)
|
fcntl.flock(lock, fcntl.LOCK_EX)
|
||||||
apply_event(event, state, path, now)
|
apply_event(event, state, path, now, claude_pid)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-11
@@ -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:
|
// 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
|
// 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.
|
// 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". */
|
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
|
||||||
function segments(name) {
|
function segments(name) {
|
||||||
@@ -22,29 +32,35 @@ function segments(name) {
|
|||||||
.filter(Boolean);
|
.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
|
* Initials for multi-segment names, first letters for single words. Initials
|
||||||
* matter more than they look: a plain prefix collapses "dev-skills" and
|
* 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
|
* "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 ?? ''));
|
const parts = segments(String(name ?? ''));
|
||||||
if (!parts.length)
|
if (!parts.length)
|
||||||
return '?';
|
return '?';
|
||||||
const raw = parts.length > 1
|
const raw = parts.length > 1
|
||||||
? parts.map(p => p[0]).join('')
|
? parts.map(p => p[0]).join('')
|
||||||
: parts[0];
|
: 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.
|
/** 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.
|
* `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.
|
* 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 labels = new Map();
|
||||||
const taken = new Set();
|
const taken = new Set();
|
||||||
|
|
||||||
@@ -61,13 +77,13 @@ export function assignChips(sessions, previous = new Map()) {
|
|||||||
.sort((a, b) => (a.since || 0) - (b.since || 0));
|
.sort((a, b) => (a.since || 0) - (b.since || 0));
|
||||||
|
|
||||||
for (const session of fresh) {
|
for (const session of fresh) {
|
||||||
const base = abbreviate(session.base);
|
const base = abbreviate(session.base, max);
|
||||||
let label = base;
|
let label = base;
|
||||||
// Digits eat into the base rather than extending past three characters,
|
// Digits eat into the base rather than extending past the width, so
|
||||||
// so every chip stays the same width and the row does not ripple.
|
// every chip stays the same size and the row does not ripple.
|
||||||
for (let n = 2; taken.has(label); n++) {
|
for (let n = 2; taken.has(label); n++) {
|
||||||
const suffix = String(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);
|
labels.set(session.sessionId, label);
|
||||||
taken.add(label);
|
taken.add(label);
|
||||||
|
|||||||
+23
-5
@@ -73,7 +73,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
// other way -- another extension rebuilding the panel boxes -- would
|
// other way -- another extension rebuilding the panel boxes -- would
|
||||||
// leave the timer and the file monitor running against a disposed
|
// leave the timer and the file monitor running against a disposed
|
||||||
// actor, screaming into the log every 20 seconds.
|
// 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();
|
this._store.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,8 +88,8 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
_buildPanel() {
|
_buildPanel() {
|
||||||
// One chip per session rather than one aggregate: with five projects
|
// One chip per session rather than one aggregate: with five projects
|
||||||
// open, "the most urgent one" answers a question you did not ask. The
|
// open, "the most urgent one" answers a question you did not ask.
|
||||||
// row sits right of the clock, so it grows away from the centre.
|
// Where the row sits in the panel is a setting; extension.js places it.
|
||||||
this._chipBox = new St.BoxLayout({
|
this._chipBox = new St.BoxLayout({
|
||||||
style_class: 'panel-status-menu-box ccs-panel-box',
|
style_class: 'panel-status-menu-box ccs-panel-box',
|
||||||
y_align: Clutter.ActorAlign.CENTER,
|
y_align: Clutter.ActorAlign.CENTER,
|
||||||
@@ -176,6 +183,17 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
const showAge = this._settings.get_boolean('show-age');
|
const showAge = this._settings.get_boolean('show-age');
|
||||||
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
||||||
const maxChips = this._settings.get_int('max-chips');
|
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(
|
this._chipLabels = assignChips(
|
||||||
sessions.map(s => ({
|
sessions.map(s => ({
|
||||||
@@ -186,7 +204,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
since: s.started || s.since,
|
since: s.started || s.since,
|
||||||
base: projectName(s.cwd),
|
base: projectName(s.cwd),
|
||||||
})),
|
})),
|
||||||
this._chipLabels);
|
this._chipLabels, width);
|
||||||
|
|
||||||
const labelFor = session => abbreviate
|
const labelFor = session => abbreviate
|
||||||
? this._chipLabels.get(session.sessionId)
|
? this._chipLabels.get(session.sessionId)
|
||||||
@@ -398,7 +416,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
|||||||
|
|
||||||
// ---- Teardown -------------------------------------------------------
|
// ---- Teardown -------------------------------------------------------
|
||||||
|
|
||||||
_onDestroy() {
|
_teardown() {
|
||||||
if (this._destroyed)
|
if (this._destroyed)
|
||||||
return;
|
return;
|
||||||
this._destroyed = true;
|
this._destroyed = true;
|
||||||
|
|||||||
@@ -15,15 +15,38 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
|||||||
});
|
});
|
||||||
window.add(page);
|
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);
|
page.add(dispGroup);
|
||||||
|
|
||||||
dispGroup.add(this._switchRow(settings, 'show-project-name',
|
dispGroup.add(this._switchRow(settings, 'show-project-name',
|
||||||
_('Label chips with the project'),
|
_('Label chips with the project'),
|
||||||
_('Name the sessions, not just their states.')));
|
_('Name the sessions, not just their states.')));
|
||||||
dispGroup.add(this._switchRow(settings, 'abbreviate-names',
|
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.')));
|
_('“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',
|
dispGroup.add(this._spinRow(settings, 'max-chips',
|
||||||
_('Chips shown'),
|
_('Chips shown'),
|
||||||
_('The most urgent sessions get a chip; the rest are counted as “+N”.'),
|
_('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;
|
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) {
|
_switchRow(settings, key, title, subtitle) {
|
||||||
const row = new Adw.SwitchRow({ title, subtitle });
|
const row = new Adw.SwitchRow({ title, subtitle });
|
||||||
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
|
settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||||
|
|||||||
@@ -2,6 +2,22 @@
|
|||||||
<schemalist>
|
<schemalist>
|
||||||
<schema id="org.gnome.shell.extensions.claude-code-status"
|
<schema id="org.gnome.shell.extensions.claude-code-status"
|
||||||
path="/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">
|
<key name="show-project-name" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
<summary>Label each chip with its project</summary>
|
<summary>Label each chip with its project</summary>
|
||||||
@@ -9,9 +25,15 @@
|
|||||||
</key>
|
</key>
|
||||||
<key name="abbreviate-names" type="b">
|
<key name="abbreviate-names" type="b">
|
||||||
<default>true</default>
|
<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>
|
<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>
|
||||||
|
<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">
|
<key name="max-chips" type="i">
|
||||||
<default>3</default>
|
<default>3</default>
|
||||||
<range min="1" max="12"/>
|
<range min="1" max="12"/>
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ check('camelCase counts as segments', 'om', abbreviate('outlineMcp'));
|
|||||||
check('digits survive', 'p2', abbreviate('proj_2'));
|
check('digits survive', 'p2', abbreviate('proj_2'));
|
||||||
check('empty name does not crash', '?', abbreviate(''));
|
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 ------------------------------------------------------------
|
// --- collisions ------------------------------------------------------------
|
||||||
const two = [
|
const two = [
|
||||||
{ sessionId: 'a', since: 100, base: 'dev-skills' },
|
{ 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',
|
check('twelve sessions in one project are all distinct',
|
||||||
12, new Set(wide.values()).size);
|
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');
|
out(failures ? `\n${failures} failure(s)` : '\nall passed');
|
||||||
if (typeof imports !== 'undefined')
|
if (typeof imports !== 'undefined')
|
||||||
imports.system.exit(failures ? 1 : 0);
|
imports.system.exit(failures ? 1 : 0);
|
||||||
|
|||||||
+38
-9
@@ -41,22 +41,51 @@ import importlib.util, sys
|
|||||||
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
spec = importlib.util.spec_from_file_location("h", sys.argv[1])
|
||||||
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
h = importlib.util.module_from_spec(spec); spec.loader.exec_module(h)
|
||||||
cases = [
|
cases = [
|
||||||
("/bin/sh -c /home/u/claude-code-gnome-extension/hooks/claude-status-hook.py ", False),
|
(["/bin/sh", "-c", "/home/u/claude-code-gnome-extension/hooks/claude-status-hook.py"], False),
|
||||||
("/home/u/.local/bin/claude --resume ", True),
|
(["/home/u/.local/bin/claude", "--resume"], True),
|
||||||
("bash /home/u/bin/claude ", True),
|
(["bash", "/home/u/bin/claude"], True),
|
||||||
("node /usr/lib/node_modules/@anthropic-ai/claude-code/cli.js ", True),
|
(["node", "/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js"], True),
|
||||||
("/home/u/bin/zellij --server /run/user/1000/zellij/x ", False),
|
(["/home/u/bin/zellij", "--server", "/run/user/1000/zellij/x"], False),
|
||||||
("nvim /home/u/.claude/settings.json ", False),
|
(["nvim", "/home/u/.claude/settings.json"], False),
|
||||||
]
|
]
|
||||||
bad = 0
|
bad = 0
|
||||||
for cmd, want in cases:
|
for argv, want in cases:
|
||||||
got = h.looks_like_claude(cmd)
|
got = h.looks_like_claude(argv)
|
||||||
print(("ok " if got == want else "FAIL ") + "claude in %r -> %s" % (cmd[:46], got))
|
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
|
bad += got != want
|
||||||
sys.exit(1 if bad else 0)
|
sys.exit(1 if bad else 0)
|
||||||
PY
|
PY
|
||||||
check "command lines classified correctly" "0" "$?"
|
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 ---------------------------------------------------------
|
# --- state machine ---------------------------------------------------------
|
||||||
emit "$(ev SessionStart '"source":"startup"')"
|
emit "$(ev SessionStart '"source":"startup"')"
|
||||||
check "SessionStart -> waiting" "waiting" "$(field state)"
|
check "SessionStart -> waiting" "waiting" "$(field state)"
|
||||||
|
|||||||
+34
-6
@@ -36,10 +36,13 @@ const schemas = Gio.SettingsSchemaSource.new_from_directory(
|
|||||||
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
|
const { default: Prefs } = await import(`file://${tmp}/prefs.js`);
|
||||||
const prefs = new Prefs();
|
const prefs = new Prefs();
|
||||||
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
|
Object.defineProperty(prefs, 'path', { value: EXT }); // a getter upstream
|
||||||
prefs.getSettings = () => new Gio.Settings({
|
// A memory backend, not the default one: the test writes a key to check the
|
||||||
settings_schema: schemas.lookup(
|
// hand-rolled combo binding, and it has no business touching the settings of
|
||||||
'org.gnome.shell.extensions.claude-code-status', true),
|
// 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();
|
const window = new Adw.PreferencesWindow();
|
||||||
prefs.fillPreferencesWindow(window);
|
prefs.fillPreferencesWindow(window);
|
||||||
@@ -48,7 +51,8 @@ const rows = [];
|
|||||||
const walk = widget => {
|
const walk = widget => {
|
||||||
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
|
for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) {
|
||||||
const type = c.constructor.$gtype.name;
|
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 });
|
rows.push({ type, title: c.title, subtitle: c.subtitle });
|
||||||
walk(c);
|
walk(c);
|
||||||
}
|
}
|
||||||
@@ -70,9 +74,33 @@ for (const row of rows)
|
|||||||
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
|
const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true)
|
||||||
.list_keys().length;
|
.list_keys().length;
|
||||||
const controls = rows.filter(
|
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}`);
|
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
|
// 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.
|
// silent panel looks the same whether nothing runs or nothing is installed.
|
||||||
const status = rows.find(r => r.title === 'Status');
|
const status = rows.find(r => r.title === 'Status');
|
||||||
|
|||||||
Reference in New Issue
Block a user