diff --git a/README.md b/README.md index 063e596..9237692 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,10 @@ Code ждёт меня прямо сейчас?** текстом `«Claude needs your permission»`. Различить их можно было бы только через `PreToolUse` — ценой записи файла на каждый вызов инструмента. +Чипов показывается три (настраивается), остальные сворачиваются в `+N`. Панель +живёт в центральном боксе рядом с часами, и без предела достаточно открытых +сессий сдвинули бы часы с центра. + Чипы идут по срочности, а внутри одного состояния первой стоит **самая давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в состоянии показывается только у первого чипа: пять счётчиков рядом — это @@ -178,13 +182,17 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me ## zellij -Если сессии живут в табах zellij, меню показывает **имя таба**, а клик -переключает на него. Поиск идёт через `zellij action dump-layout` — рабочий -каталог сессии сопоставляется с каталогами пейнов, потому что в дампе раскладки -нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится. Отсюда следствия: две -сессии в одном табе неразличимы, а сессия, сменившая `cwd` после открытия пейна, -не найдётся. Строки, которые не разрешились, остаются некликабельными — вместо -того чтобы делать вид, будто клик что-то делает. +Если сессии живут в табах zellij, меню называет **имя таба** — это лучший ответ +на «в какой терминал идти», чем путь. Поиск идёт через `zellij action +dump-layout`: рабочий каталог сессии сопоставляется с каталогами пейнов, потому +что в дампе раскладки нет id пейнов и `ZELLIJ_PANE_ID` для этого не годится. +Отсюда следствия: две сессии в одном табе неразличимы, а сессия, сменившая +`cwd` после открытия пейна, не найдётся. + +Меню **ничего не делает** — только показывает. Ни строки, ни чипы не кликабельны: +переключение таба и подъём окна были написаны и убраны, потому что стоили заметной +логики (окна gnome-terminal нельзя сопоставить по pid — все они под одним +серверным процессом) ради экономии одного alt-tab. Если zellij не используется, выключите в настройках: он стоит одного процесса раз в пару минут. @@ -198,11 +206,6 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me путается с «мёртв», — и такие записи истекают по возрасту, через 36 часов. - **`Stop` не отличает «закончил» от «сдался».** Оба читаются как «ждёт ввода», и решение для вас в обоих случаях одно и то же. -- **Фокус ведёт к табу zellij, а не к окну.** gnome-terminal держит все окна под - одним общим серверным процессом, так что окно нельзя сопоставить по pid. Окно - поднимается, только если в его заголовке есть имя zellij-сессии; когда - совпадения нет, таб всё равно переключается, а фокус остаётся на месте — - поднять произвольный терминал хуже, чем не поднимать никакой. ## Тесты diff --git a/lib/indicator.js b/lib/indicator.js index fa9649b..6929613 100644 --- a/lib/indicator.js +++ b/lib/indicator.js @@ -6,7 +6,6 @@ import Clutter from 'gi://Clutter'; import GLib from 'gi://GLib'; import Pango from 'gi://Pango'; -import * as Main from 'resource:///org/gnome/shell/ui/main.js'; import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.js'; @@ -27,11 +26,6 @@ function stateLabel(state) { } } -const TERMINAL_CLASSES = [ - 'gnome-terminal', 'org.gnome.terminal', 'kitty', 'alacritty', - 'foot', 'wezterm', 'konsole', 'xterm', 'ghostty', -]; - /** Paint a state glyph in the panel's own text colour. * * Nothing here picks a colour: the foreground comes from the theme node, so @@ -164,6 +158,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button { _updatePanel(sessions) { const showAge = this._settings.get_boolean('show-age'); const abbreviate = this._settings.get_boolean('abbreviate-names'); + const maxChips = this._settings.get_int('max-chips'); this._chipLabels = assignChips( sessions.map(s => ({ @@ -181,23 +176,38 @@ class ClaudeStatusIndicator extends PanelMenu.Button { // than sitting there empty. this.visible = sessions.length > 0; + // Chips are ordered by urgency, so cutting the tail keeps the ones that + // need you soonest. Without a cap the row grows without bound, and it + // sits in the centre box -- enough sessions would shove the clock off + // centre. Labels are still assigned over every session, so the menu and + // the panel agree and a chip does not change when the cap does. + const shown = sessions.slice(0, maxChips); + const hidden = sessions.length - shown.length; + // Age rides on the first chip only. Sessions are sorted by urgency, so // that is the one whose age decides anything; five ages side by side // would just be a wide row of numbers. - const ageOnFirst = showAge && sessions.length > 0; - const signature = sessions + const ageOnFirst = showAge && shown.length > 0; + const signature = shown .map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`) - .join('|') + `|${ageOnFirst}`; + .join('|') + `|${ageOnFirst}|${hidden}`; if (signature !== this._chipSignature) { this._chipBox.destroy_all_children(); this._ageLabel = null; - sessions.forEach((session, i) => { + shown.forEach((session, i) => { const { chip, age } = this._buildChip( session, labelFor(session), ageOnFirst && i === 0); if (age) this._ageLabel = { age, session }; this._chipBox.add_child(chip); }); + if (hidden > 0) { + this._chipBox.add_child(new St.Label({ + style_class: 'ccs-overflow', + y_align: Clutter.ActorAlign.CENTER, + text: `+${hidden}`, + })); + } this._chipSignature = signature; } @@ -210,8 +220,10 @@ class ClaudeStatusIndicator extends PanelMenu.Button { // Rebuild only when the set of sessions or their states changed; ages // alone are refreshed in place so an open menu does not flicker. + // The tab name is not in the signature: it only feeds the subtitle, + // which is refreshed in place below. const signature = sessions - .map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`) + .map(s => `${s.sessionId}:${s.state}:${this._chipLabels?.get(s.sessionId) ?? ''}`) .join('|'); if (signature !== this._rowSignature) { this._rebuildRows(sessions); @@ -259,13 +271,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button { } _buildRow(session) { - const tab = this._tabFor(session); - // Reactivity is decided at construction, not patched afterwards: - // PopupBaseMenuItem latches _activatable in its constructor, so a row - // switched to reactive=false later keeps the styling of a clickable one - // and still looks like it does something. + // The menu reports; it does not act. Rows are built inert rather than + // switched off afterwards, because PopupBaseMenuItem latches + // _activatable in its constructor and a row demoted later keeps the + // styling of a clickable one. const item = new PopupMenu.PopupBaseMenuItem( - tab ? {} : { reactive: false, can_focus: false }); + { reactive: false, can_focus: false }); item.add_style_class_name('ccs-row'); const column = new St.BoxLayout({ vertical: true, x_expand: true }); @@ -296,9 +307,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button { column.add_child(subtitle); item.add_child(column); - if (tab) - item.connect('activate', () => this._switchTo(session, tab)); - this._rows.push({ sessionId: session.sessionId, age, subtitle }); return item; } @@ -360,36 +368,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button { .catch(e => logError(e, 'claude-code-status: zellij refresh failed')); } - _switchTo(session, tab) { - this._zellij.goToTab(session.zellijSession, tab); - this._focusTerminal(session); - } - - /** Best effort: raise a terminal window showing this zellij session. - * - * Matching by pid does not work for gnome-terminal, where every window - * belongs to one shared server process, so the window title is the only - * handle available -- and zellij puts the session name there. - */ - _focusTerminal(session) { - if (!session.zellijSession) - return; - // list_all_windows() rather than get_window_actors(): the latter is - // deprecated from GNOME 46 on, and this has to work across 45-48. - for (const win of global.display.list_all_windows()) { - const wmClass = (win.get_wm_class() ?? '').toLowerCase(); - if (!TERMINAL_CLASSES.some(c => wmClass.includes(c))) - continue; - // Only a window that names this zellij session is raised. Falling - // back to any terminal at all would raise an unrelated one, which - // is worse than leaving focus where the user put it. - if ((win.get_title() ?? '').includes(session.zellijSession)) { - Main.activateWindow(win); - return; - } - } - } - // ---- Visuals -------------------------------------------------------- // ---- Teardown ------------------------------------------------------- diff --git a/lib/zellij.js b/lib/zellij.js index b95af4f..3bf0e39 100644 --- a/lib/zellij.js +++ b/lib/zellij.js @@ -1,8 +1,8 @@ // Maps a session's working directory to the zellij tab it is running in. // // Knowing a session waits for you is only half the answer; the other half is -// where to look. When sessions live in zellij tabs, the tab name is a better -// answer than a path, and zellij can be told to switch to it. +// where to look. When sessions live in zellij tabs, the tab name answers that +// better than a path does. // // `zellij action dump-layout` prints tab names with each pane's cwd but no pane // ids, so ZELLIJ_PANE_ID from the hook cannot be used for the lookup and the @@ -115,17 +115,6 @@ export class ZellijTabs { this._cache.clear(); this._inFlight.clear(); } - - /** Switch the given zellij session to a tab. Fire and forget. */ - goToTab(session, tab) { - try { - Gio.Subprocess.new( - ['zellij', '--session', session, 'action', 'go-to-tab-name', tab], - Gio.SubprocessFlags.STDOUT_SILENCE | Gio.SubprocessFlags.STDERR_SILENCE); - } catch (e) { - logError(e, 'claude-code-status: zellij go-to-tab-name failed'); - } - } } /** Extract tab names and their pane working directories from a KDL layout. diff --git a/prefs.js b/prefs.js index 2851829..2926abb 100644 --- a/prefs.js +++ b/prefs.js @@ -24,6 +24,10 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { dispGroup.add(this._switchRow(settings, 'abbreviate-names', _('Shorten names to three characters'), _('“dev-skills” becomes “ds”. Collisions get a digit by seniority, so a label already on screen never changes.'))); + dispGroup.add(this._spinRow(settings, 'max-chips', + _('Chips shown'), + _('The most urgent sessions get a chip; the rest are counted as “+N”.'), + 1, 12)); dispGroup.add(this._switchRow(settings, 'show-age', _('Show time in state'), _('Shown on the most urgent session only.'))); @@ -36,7 +40,7 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { zellijGroup.add(this._switchRow(settings, 'zellij-integration', _('Resolve tab names'), - _('Show the zellij tab in the menu and switch to it on click. Ignored when zellij is not installed.'))); + _('Name the zellij tab each session runs in. Ignored when zellij is not installed.'))); // --- Hooks --------------------------------------------------------- // The indicator is only as good as the hooks feeding it, and a silent @@ -98,6 +102,17 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences { } } + _spinRow(settings, key, title, subtitle, lower, upper) { + const row = new Adw.SpinRow({ + title, subtitle, + adjustment: new Gtk.Adjustment({ + lower, upper, step_increment: 1, page_increment: 1, + }), + }); + settings.bind(key, row, 'value', Gio.SettingsBindFlags.DEFAULT); + return row; + } + _switchRow(settings, key, title, subtitle) { const row = new Adw.SwitchRow({ title, subtitle }); settings.bind(key, row, 'active', Gio.SettingsBindFlags.DEFAULT); diff --git a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml index aa84ac3..8d9ae02 100644 --- a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml +++ b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml @@ -12,6 +12,12 @@ Shorten project names to three characters 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. + + 3 + + How many sessions get a chip + Chips are ordered by urgency, so the ones shown are the ones that need you soonest; the rest are counted as "+N". Keeps the row from pushing the clock off centre when many sessions are open. + true Show how long the session has been in this state @@ -20,7 +26,7 @@ true Resolve zellij tab names - Look up which zellij tab each session runs in, show it in the menu, and let a click switch to that tab. Requires the zellij command; harmless when it is absent. + Look up which zellij tab each session runs in and name it in the menu, which answers "which terminal" better than a path does. Requires the zellij command; harmless when it is absent. diff --git a/stylesheet.css b/stylesheet.css index fa1dd75..afb6a41 100644 --- a/stylesheet.css +++ b/stylesheet.css @@ -34,6 +34,12 @@ font-feature-settings: "tnum"; } +.ccs-overflow { + font-size: 0.9em; + opacity: 0.7; + font-feature-settings: "tnum"; +} + .ccs-summary { font-weight: bold; } diff --git a/tests/test-prefs.js b/tests/test-prefs.js index fdd8023..3be96ec 100644 --- a/tests/test-prefs.js +++ b/tests/test-prefs.js @@ -48,7 +48,7 @@ const rows = []; const walk = widget => { for (let c = widget.get_first_child?.(); c; c = c.get_next_sibling()) { const type = c.constructor.$gtype.name; - if (type === 'AdwSwitchRow' || type === 'AdwActionRow') + if (type === 'AdwSwitchRow' || type === 'AdwSpinRow' || type === 'AdwActionRow') rows.push({ type, title: c.title, subtitle: c.subtitle }); walk(c); } @@ -65,11 +65,13 @@ function check(name, condition, detail = '') { for (const row of rows) print(` ${row.type.replace('Adw', '').padEnd(10)} ${row.title}`); -// One switch per settings key, so a key added without a row is caught. +// One control per settings key, so a key added without a row to change it is +// caught here rather than by a user wondering why nothing happens. const keys = schemas.lookup('org.gnome.shell.extensions.claude-code-status', true) .list_keys().length; -const switches = rows.filter(r => r.type === 'AdwSwitchRow').length; -check('a switch for every settings key', switches === keys, `${switches} of ${keys}`); +const controls = rows.filter( + r => r.type === 'AdwSwitchRow' || r.type === 'AdwSpinRow').length; +check('a control for every settings key', controls === keys, `${controls} of ${keys}`); // The hook status line is the reason this page is worth opening at all: a // silent panel looks the same whether nothing runs or nothing is installed.