Show one chip per session, right of the clock
The panel showed a single aggregate: the most urgent session, plus a "+N" for the others. That answers "what is the worst thing happening", which is not the question with five projects open -- "what is each of them doing" needs each of them on screen. Each session now gets a chip: state glyph plus a three-character project label, in the centre box just right of the clock. Time in state stays on the first chip only; five counters side by side are a row of numbers, not an answer. Labels are initials for multi-segment names, first letters otherwise. Initials rather than a prefix, because a prefix collapses dev-skills and dev-conventions onto the same "dev" -- exactly the pair that has to stay apart. Collisions, including two sessions in one project where the cwd is identical, take a digit: ds, ds2, ds3. Two rules keep a label still, and the feature is worthless without them. Assignment runs oldest-session-first, so a session starting now takes the suffix instead of displacing one already on screen; and a label belongs to its session until it ends, even after whatever forced the digit has closed. A label that moves under your hand is worse than one carrying a digit that no longer looks necessary. The zellij tab name was considered as the label source and dropped. It arrives asynchronously from dump-layout, so a sticky label would freeze whatever the project name produced first and never adopt it. Digits already separate same-project sessions, so the tab name buys nothing here and stays where it is useful, in the menu. Menu rows now lead with the same label, so the mapping from "ds" to dev-skills is read rather than guessed.
This commit is contained in:
@@ -4,25 +4,70 @@
|
||||
Code ждёт меня прямо сейчас?**
|
||||
|
||||
Когда открыто несколько сессий в разных проектах, дорого не «не знать, чем
|
||||
каждая занята», а не заметить, что одна из них встала час назад. Панель
|
||||
называет сессию, а не только состояние.
|
||||
каждая занята», а не заметить, что одна из них встала час назад.
|
||||
|
||||
Справа от часов стоит по чипу на сессию — значок состояния и трёхбуквенное
|
||||
имя проекта:
|
||||
|
||||
```
|
||||
◉ ds ● dc ● ds2 ○ pps ◌ umb 9:41
|
||||
│ │ │ │ └ umbar, тихо
|
||||
│ │ │ └ pet-project-server, работает
|
||||
│ │ └ вторая сессия в dev-skills, ждёт
|
||||
│ └ dev-conventions, ждёт
|
||||
└ dev-skills, упёрлась в разрешение
|
||||
```
|
||||
|
||||
## Состояния
|
||||
|
||||
| В панели | Состояние | Что значит |
|
||||
| Значок | Состояние | Что значит |
|
||||
|---|---|---|
|
||||
| диск в кольце | `blocked` | упёрлась в запрос разрешения — без вас не сдвинется |
|
||||
| закрашенный диск | `waiting` | ход закончен, ждёт вашего ввода |
|
||||
| кольцо | `busy` | работает |
|
||||
| тусклое пунктирное кольцо | `idle` | запущена, но ничего не просили, либо ничего не запущено |
|
||||
| тусклое пунктирное кольцо | `idle` | запущена, но ничего не просили |
|
||||
|
||||
`blocked` и `waiting` разведены намеренно. Слитые в одно «требует внимания»,
|
||||
законченная задача выглядит так же срочно, как заблокированная, — а именно это
|
||||
различие и решает, переключаться сейчас или после текущей мысли.
|
||||
|
||||
Панель показывает **самую приоритетную** сессию и счётчик `+N`, если в том же
|
||||
состоянии есть другие. Внутри состояния выигрывает **самая давняя**: забывается
|
||||
та, что ждёт дольше всех, а не последняя.
|
||||
Чипы идут по срочности, а внутри одного состояния первой стоит **самая
|
||||
давняя**: забывается та, что ждёт дольше всех, а не последняя. Время в
|
||||
состоянии показывается только у первого чипа: пять счётчиков рядом — это
|
||||
ряд чисел, а не ответ на вопрос.
|
||||
|
||||
## Метки чипов
|
||||
|
||||
Имя проекта сжимается до трёх знаков: если в имени несколько сегментов
|
||||
(`-`, `_`, camelCase) — инициалы, иначе первые буквы.
|
||||
|
||||
| Проект | Чип |
|
||||
|---|---|
|
||||
| `dev-skills` | `ds` |
|
||||
| `dev-conventions` | `dc` |
|
||||
| `pet-project-server` | `pps` |
|
||||
| `claude-code-gnome-extension` | `ccg` |
|
||||
| `jellybit` | `jel` |
|
||||
|
||||
Инициалы, а не просто префикс, важны больше, чем кажется: префикс слил бы
|
||||
`dev-skills` и `dev-conventions` в один `dev` — ровно тот случай, который надо
|
||||
развести.
|
||||
|
||||
При совпадении — включая две сессии в одном проекте, где `cwd` один и тот же —
|
||||
добавляется цифра по старшинству: `ds`, `ds2`, `ds3`. Цифра съедает базу, а не
|
||||
удлиняет метку, чтобы ряд не расползался.
|
||||
|
||||
Два правила держат метки на месте, и без них вся затея рассыпается:
|
||||
|
||||
- Назначение идёт **от самой давней сессии**, так что цифру берёт только что
|
||||
запущенная, а не та, на которую ты сейчас смотришь.
|
||||
- Выданная метка закреплена за сессией до её конца — даже после того, как
|
||||
закрылась сессия, из-за которой появилась цифра. Метка, переехавшая под
|
||||
рукой, хуже метки с цифрой, которая уже не выглядит нужной.
|
||||
|
||||
Сокращение отключается в настройках — тогда в чипах полные имена проектов.
|
||||
В меню строка начинается с той же метки, чтобы соответствие «`ds` — это
|
||||
dev-skills» читалось, а не угадывалось.
|
||||
|
||||
**Уведомлений на рабочий стол нет** — сознательно. `notify-send` из хука было бы
|
||||
куда дешевле сделать, но форма неверная: сессия, ждущая двадцать минут, должна
|
||||
@@ -121,11 +166,13 @@ gnome-extensions enable claude-code-status@git.vakhrushev.me
|
||||
|
||||
```sh
|
||||
gjs -m tests/test-sessions.js # чтение состояния, порядок, живость, слежение
|
||||
gjs -m tests/test-abbrev.js # метки чипов: сжатие, коллизии, липкость
|
||||
tests/test-hook.sh # события -> состояния, блокировка, снос файлов
|
||||
```
|
||||
|
||||
`lib/sessions.js` намеренно ничего не импортирует из `resource:///org/gnome/shell`
|
||||
— именно это позволяет гонять его в обычном `gjs`, вне композитора.
|
||||
`lib/sessions.js` намеренно ничего не импортирует из `resource:///org/gnome/shell`,
|
||||
а `lib/abbrev.js` — вообще ничего. Именно это позволяет гонять их вне
|
||||
композитора — в `gjs`, а `abbrev` и в `node`.
|
||||
|
||||
Чтобы посмотреть сырые события хуков, создайте файл-маркер, и каждое событие
|
||||
будет дописываться в него:
|
||||
|
||||
+5
-1
@@ -6,7 +6,11 @@ import { ClaudeStatusIndicator } from './lib/indicator.js';
|
||||
export default class ClaudeCodeStatusExtension extends Extension {
|
||||
enable() {
|
||||
this._indicator = new ClaudeStatusIndicator(this);
|
||||
Main.panel.addToStatusArea(this.uuid, this._indicator);
|
||||
// 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');
|
||||
}
|
||||
|
||||
disable() {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// Three-character chip labels for the panel.
|
||||
//
|
||||
// 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
|
||||
// suffixed variant rather than pushing one off the label already on screen; and
|
||||
// a label, once given, belongs to that session until it ends -- even after the
|
||||
// session it was disambiguated against has closed. A label that moves under
|
||||
// your hand is worse than one carrying a digit that no longer looks necessary.
|
||||
//
|
||||
// Imports nothing, so it runs under plain node or gjs.
|
||||
|
||||
const MAX = 3;
|
||||
|
||||
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
|
||||
function segments(name) {
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Up to three 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.
|
||||
*/
|
||||
export function abbreviate(name) {
|
||||
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();
|
||||
}
|
||||
|
||||
/** 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.
|
||||
*/
|
||||
export function assignChips(sessions, previous = new Map()) {
|
||||
const labels = new Map();
|
||||
const taken = new Set();
|
||||
|
||||
for (const session of sessions) {
|
||||
const kept = previous.get(session.sessionId);
|
||||
if (kept !== undefined) {
|
||||
labels.set(session.sessionId, kept);
|
||||
taken.add(kept);
|
||||
}
|
||||
}
|
||||
|
||||
const fresh = sessions
|
||||
.filter(s => !labels.has(s.sessionId))
|
||||
.sort((a, b) => (a.since || 0) - (b.since || 0));
|
||||
|
||||
for (const session of fresh) {
|
||||
const base = abbreviate(session.base);
|
||||
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.
|
||||
for (let n = 2; taken.has(label); n++) {
|
||||
const suffix = String(n);
|
||||
label = base.slice(0, Math.max(1, MAX - suffix.length)) + suffix;
|
||||
}
|
||||
labels.set(session.sessionId, label);
|
||||
taken.add(label);
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
+126
-92
@@ -13,6 +13,7 @@ import { gettext as _ } from 'resource:///org/gnome/shell/extensions/extension.j
|
||||
|
||||
import { SessionStore, STATES } from './sessions.js';
|
||||
import { ZellijTabs } from './zellij.js';
|
||||
import { assignChips } from './abbrev.js';
|
||||
import { formatAge, projectName, shortenHome } from './format.js';
|
||||
|
||||
// Translated lazily: gettext is not bound yet while modules are being imported.
|
||||
@@ -31,6 +32,55 @@ const TERMINAL_CLASSES = [
|
||||
'foot', 'wezterm', 'konsole', 'xterm', 'ghostty',
|
||||
];
|
||||
|
||||
/** Paint the state glyph in the actor's inherited foreground colour, so the
|
||||
* stylesheet decides the colour and this stays theme-agnostic. */
|
||||
function drawStateDot(area, state) {
|
||||
const cr = area.get_context();
|
||||
try {
|
||||
const [w, h] = area.get_surface_size();
|
||||
const color = area.get_theme_node().get_foreground_color();
|
||||
const r = color.red / 255;
|
||||
const g = color.green / 255;
|
||||
const b = color.blue / 255;
|
||||
const a = color.alpha / 255;
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) / 2 - 1.5;
|
||||
|
||||
cr.setLineWidth(1.5);
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
|
||||
switch (state) {
|
||||
case 'blocked':
|
||||
// Disc inside a ring: the loudest shape, for the only state where
|
||||
// a session is stuck until you act.
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
cr.arc(cx, cy, radius * 0.5, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'waiting':
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'busy':
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
break;
|
||||
default:
|
||||
cr.setDash([2, 2], 0);
|
||||
cr.setSourceRGBA(r, g, b, a * 0.5);
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
cr.setDash([], 0);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
cr.$dispose();
|
||||
}
|
||||
}
|
||||
|
||||
export const ClaudeStatusIndicator = GObject.registerClass(
|
||||
class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
_init(extension) {
|
||||
@@ -40,7 +90,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
this._settings = extension.getSettings();
|
||||
this._store = new SessionStore();
|
||||
this._zellij = new ZellijTabs();
|
||||
this._dotState = 'none';
|
||||
this._chipLabels = new Map();
|
||||
this._rows = [];
|
||||
|
||||
this._buildPanel();
|
||||
@@ -54,34 +104,54 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
// ---- Panel widget -------------------------------------------------
|
||||
|
||||
_buildPanel() {
|
||||
const box = new St.BoxLayout({
|
||||
// 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.
|
||||
this._chipBox = new St.BoxLayout({
|
||||
style_class: 'panel-status-menu-box ccs-panel-box',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
this.add_child(this._chipBox);
|
||||
}
|
||||
|
||||
// Shape carries the state as well as colour does, so the indicator
|
||||
// still reads under a monochrome theme or with colour vision deficiency:
|
||||
_buildChip(session, label, withAge) {
|
||||
const chip = new St.BoxLayout({
|
||||
style_class: `ccs-chip ccs-${session.state}`,
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
|
||||
// Shape carries the state as well as colour does, so the row still
|
||||
// reads under a monochrome theme or with colour vision deficiency:
|
||||
// filled disc in a ring — blocked on a permission prompt;
|
||||
// filled disc — turn finished, waiting for input;
|
||||
// bright ring — working;
|
||||
// dim dashed ring — idle, or nothing running.
|
||||
this._dot = new St.DrawingArea({
|
||||
style_class: 'ccs-dot ccs-none',
|
||||
// dim dashed ring — idle.
|
||||
const dot = new St.DrawingArea({
|
||||
style_class: 'ccs-dot',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
});
|
||||
this._dot.set_width(16);
|
||||
this._dot.set_height(16);
|
||||
this._dot.connect('repaint', area => this._drawDot(area));
|
||||
box.add_child(this._dot);
|
||||
dot.set_width(12);
|
||||
dot.set_height(12);
|
||||
dot.connect('repaint', area => drawStateDot(area, session.state));
|
||||
chip.add_child(dot);
|
||||
|
||||
this._label = new St.Label({
|
||||
style_class: 'ccs-panel-label',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
text: '',
|
||||
});
|
||||
box.add_child(this._label);
|
||||
|
||||
this.add_child(box);
|
||||
let age = null;
|
||||
if (this._settings.get_boolean('show-project-name')) {
|
||||
chip.add_child(new St.Label({
|
||||
style_class: 'ccs-chip-label',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
text: label,
|
||||
}));
|
||||
}
|
||||
if (withAge) {
|
||||
age = new St.Label({
|
||||
style_class: 'ccs-chip-age',
|
||||
y_align: Clutter.ActorAlign.CENTER,
|
||||
text: formatAge(this._ageOf(session)),
|
||||
});
|
||||
chip.add_child(age);
|
||||
}
|
||||
return { chip, age };
|
||||
}
|
||||
|
||||
// ---- Menu ---------------------------------------------------------
|
||||
@@ -122,36 +192,46 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
|
||||
_updatePanel(sessions) {
|
||||
const hideWhenIdle = this._settings.get_boolean('hide-when-idle');
|
||||
const showName = this._settings.get_boolean('show-project-name');
|
||||
const showAge = this._settings.get_boolean('show-age');
|
||||
const abbreviate = this._settings.get_boolean('abbreviate-names');
|
||||
|
||||
const top = sessions[0] ?? null;
|
||||
const state = top?.state ?? 'none';
|
||||
// Only sessions sharing the top state are counted: "+2" must mean two
|
||||
// more that need the same thing, not two unrelated background sessions.
|
||||
const peers = top ? sessions.filter(s => s.state === top.state).length : 0;
|
||||
this._chipLabels = assignChips(
|
||||
sessions.map(s => ({
|
||||
sessionId: s.sessionId,
|
||||
since: s.since,
|
||||
base: projectName(s.cwd),
|
||||
})),
|
||||
this._chipLabels);
|
||||
|
||||
this.visible = !(hideWhenIdle && (!top || top.state === 'idle'));
|
||||
const labelFor = session => abbreviate
|
||||
? this._chipLabels.get(session.sessionId)
|
||||
: projectName(session.cwd);
|
||||
|
||||
let text = '';
|
||||
if (top && top.state !== 'idle') {
|
||||
const parts = [];
|
||||
if (showName)
|
||||
parts.push(projectName(top.cwd));
|
||||
if (showAge)
|
||||
parts.push(formatAge(this._ageOf(top)));
|
||||
text = parts.join(' · ');
|
||||
if (peers > 1)
|
||||
text = text ? `${text} +${peers - 1}` : `+${peers - 1}`;
|
||||
const busy = sessions.some(s => s.state !== 'idle');
|
||||
this.visible = sessions.length > 0 && !(hideWhenIdle && !busy);
|
||||
|
||||
// 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 && busy;
|
||||
const signature = sessions
|
||||
.map(s => `${s.sessionId}:${s.state}:${labelFor(s)}`)
|
||||
.join('|') + `|${ageOnFirst}`;
|
||||
if (signature !== this._chipSignature) {
|
||||
this._chipBox.destroy_all_children();
|
||||
this._ageLabel = null;
|
||||
sessions.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);
|
||||
});
|
||||
this._chipSignature = signature;
|
||||
}
|
||||
this._label.text = text;
|
||||
this._label.visible = text !== '';
|
||||
|
||||
if (state !== this._dotState) {
|
||||
this._dotState = state;
|
||||
this._dot.style_class = `ccs-dot ccs-${state}`;
|
||||
this._dot.queue_repaint();
|
||||
}
|
||||
if (this._ageLabel)
|
||||
this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session));
|
||||
}
|
||||
|
||||
_updateMenu(sessions) {
|
||||
@@ -160,7 +240,7 @@ 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.
|
||||
const signature = sessions
|
||||
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}`)
|
||||
.map(s => `${s.sessionId}:${s.state}:${this._tabFor(s) ?? ''}:${this._chipLabels?.get(s.sessionId) ?? ''}`)
|
||||
.join('|');
|
||||
if (signature !== this._rowSignature) {
|
||||
this._rebuildRows(sessions);
|
||||
@@ -220,8 +300,9 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
const column = new St.BoxLayout({ vertical: true, x_expand: true });
|
||||
|
||||
const top = new St.BoxLayout({ x_expand: true });
|
||||
const chip = this._chipLabels?.get(session.sessionId);
|
||||
const title = new St.Label({
|
||||
text: projectName(session.cwd),
|
||||
text: chip ? `${chip} ${projectName(session.cwd)}` : projectName(session.cwd),
|
||||
style_class: `ccs-row-title ccs-${session.state}`,
|
||||
x_expand: true,
|
||||
});
|
||||
@@ -328,53 +409,6 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
|
||||
|
||||
// ---- Visuals --------------------------------------------------------
|
||||
|
||||
_drawDot(area) {
|
||||
const cr = area.get_context();
|
||||
try {
|
||||
const [w, h] = area.get_surface_size();
|
||||
const color = area.get_theme_node().get_foreground_color();
|
||||
const r = color.red / 255;
|
||||
const g = color.green / 255;
|
||||
const b = color.blue / 255;
|
||||
const a = color.alpha / 255;
|
||||
|
||||
const cx = w / 2;
|
||||
const cy = h / 2;
|
||||
const radius = Math.min(w, h) / 2 - 2;
|
||||
|
||||
cr.setLineWidth(1.5);
|
||||
cr.setSourceRGBA(r, g, b, a);
|
||||
|
||||
switch (this._dotState) {
|
||||
case 'blocked':
|
||||
// Disc inside a ring: the loudest shape, for the only state
|
||||
// where a session is stuck until you act.
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
cr.arc(cx, cy, radius * 0.55, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'waiting':
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.fill();
|
||||
break;
|
||||
case 'busy':
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
break;
|
||||
default:
|
||||
cr.setDash([2, 2], 0);
|
||||
cr.setSourceRGBA(r, g, b, a * 0.5);
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
cr.stroke();
|
||||
cr.setDash([], 0);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
cr.$dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Teardown -------------------------------------------------------
|
||||
|
||||
destroy() {
|
||||
|
||||
@@ -19,11 +19,14 @@ export default class ClaudeCodeStatusPreferences extends ExtensionPreferences {
|
||||
page.add(dispGroup);
|
||||
|
||||
dispGroup.add(this._switchRow(settings, 'show-project-name',
|
||||
_('Show project name'),
|
||||
_('Name the session that needs attention, not just its state.')));
|
||||
_('Label chips with the project'),
|
||||
_('Name the sessions, not just their states.')));
|
||||
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._switchRow(settings, 'show-age',
|
||||
_('Show time in state'),
|
||||
_('How long it has been working, or waiting for you.')));
|
||||
_('Shown on the most urgent session only.')));
|
||||
dispGroup.add(this._switchRow(settings, 'hide-when-idle',
|
||||
_('Hide when nothing is running'),
|
||||
_('Remove the indicator from the panel while no session is active.')));
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
path="/org/gnome/shell/extensions/claude-code-status/">
|
||||
<key name="show-project-name" type="b">
|
||||
<default>true</default>
|
||||
<summary>Show the project name in the panel</summary>
|
||||
<description>Name the session that needs attention, not just its state. With several sessions running, the state alone does not say which terminal to go to.</description>
|
||||
<summary>Label each chip with its project</summary>
|
||||
<description>Name the sessions, not just their states. With several running, the state alone does not say which terminal to go to. Turn off to leave only the state glyphs.</description>
|
||||
</key>
|
||||
<key name="abbreviate-names" type="b">
|
||||
<default>true</default>
|
||||
<summary>Shorten project names to three characters</summary>
|
||||
<description>Chips show initials ("dev-skills" becomes "ds") so a row of sessions stays narrow. Sessions that would collide, including two in the same project, get a digit by seniority: the older one keeps its label. Turn off to show full project names.</description>
|
||||
</key>
|
||||
<key name="show-age" type="b">
|
||||
<default>true</default>
|
||||
|
||||
+31
-9
@@ -1,29 +1,51 @@
|
||||
/* The dot is drawn with the widget's foreground colour, so state colours are
|
||||
set here as plain `color` and the drawing code stays theme-agnostic. */
|
||||
/* State glyphs are drawn in the widget's inherited foreground colour, so state
|
||||
colours are set here as plain `color` and the drawing code stays
|
||||
theme-agnostic. The colour is set on the chip, so the dot and its label read
|
||||
as one object. */
|
||||
|
||||
.ccs-panel-box {
|
||||
spacing: 10px;
|
||||
/* Small gap so the chips read as a separate group from the clock rather
|
||||
than as part of it. */
|
||||
margin-left: 12px;
|
||||
}
|
||||
|
||||
.ccs-chip {
|
||||
spacing: 4px;
|
||||
}
|
||||
|
||||
.ccs-panel-label {
|
||||
.ccs-chip-label {
|
||||
font-size: 0.9em;
|
||||
font-weight: bold;
|
||||
/* Tabular figures keep a chip that gains a digit ("ds" -> "ds2") from
|
||||
nudging every chip after it sideways. */
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.ccs-dot.ccs-blocked,
|
||||
.ccs-row-title.ccs-blocked {
|
||||
.ccs-chip-age {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-feature-settings: "tnum";
|
||||
}
|
||||
|
||||
.ccs-blocked {
|
||||
color: #e01b24;
|
||||
}
|
||||
|
||||
.ccs-dot.ccs-waiting,
|
||||
.ccs-row-title.ccs-waiting {
|
||||
.ccs-waiting {
|
||||
color: #e5a50a;
|
||||
}
|
||||
|
||||
.ccs-dot.ccs-busy,
|
||||
.ccs-row-title.ccs-busy {
|
||||
.ccs-busy {
|
||||
color: #33d17a;
|
||||
}
|
||||
|
||||
/* Idle keeps the panel's own colour and just recedes: a session nobody is
|
||||
waiting on should not compete with the ones that need an answer. */
|
||||
.ccs-idle {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ccs-summary {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/gjs -m
|
||||
// Chip labelling: shortening, collisions, and stickiness.
|
||||
//
|
||||
// Run: gjs -m tests/test-abbrev.js (or: node tests/test-abbrev.js)
|
||||
|
||||
import { abbreviate, assignChips } from '../lib/abbrev.js';
|
||||
|
||||
let failures = 0;
|
||||
const out = typeof print === 'function' ? print : console.log;
|
||||
|
||||
function check(name, expected, actual) {
|
||||
const ok = JSON.stringify(expected) === JSON.stringify(actual);
|
||||
if (!ok)
|
||||
failures++;
|
||||
out(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` (expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)})`}`);
|
||||
}
|
||||
|
||||
// --- shortening ------------------------------------------------------------
|
||||
check('initials for multi-segment names', 'ds', abbreviate('dev-skills'));
|
||||
check('a plain prefix would have collided here', 'dc', abbreviate('dev-conventions'));
|
||||
check('three segments', 'pps', abbreviate('pet-project-server'));
|
||||
check('four segments truncate to three', 'ccg', abbreviate('claude-code-gnome-extension'));
|
||||
check('single word takes first letters', 'jel', abbreviate('jellybit'));
|
||||
check('short single word stays short', 'um', abbreviate('um'));
|
||||
check('camelCase counts as segments', 'om', abbreviate('outlineMcp'));
|
||||
check('digits survive', 'p2', abbreviate('proj_2'));
|
||||
check('empty name does not crash', '?', abbreviate(''));
|
||||
|
||||
// --- collisions ------------------------------------------------------------
|
||||
const two = [
|
||||
{ sessionId: 'a', since: 100, base: 'dev-skills' },
|
||||
{ sessionId: 'b', since: 200, base: 'dev-skills' },
|
||||
];
|
||||
let map = assignChips(two);
|
||||
check('oldest keeps the clean label', 'ds', map.get('a'));
|
||||
check('newer takes the digit', 'ds2', map.get('b'));
|
||||
|
||||
const three = [...two, { sessionId: 'c', since: 300, base: 'dev-skills' }];
|
||||
map = assignChips(three, map);
|
||||
check('third in the same project', 'ds3', map.get('c'));
|
||||
check('adding one does not disturb the first', 'ds', map.get('a'));
|
||||
check('adding one does not disturb the second', 'ds2', map.get('b'));
|
||||
|
||||
// Order of the input array must not matter -- only start time does.
|
||||
const shuffled = [three[2], three[0], three[1]];
|
||||
const fromScratch = assignChips(shuffled);
|
||||
check('assignment follows start time, not array order',
|
||||
['ds', 'ds2', 'ds3'],
|
||||
[fromScratch.get('a'), fromScratch.get('b'), fromScratch.get('c')]);
|
||||
|
||||
// --- stickiness ------------------------------------------------------------
|
||||
// The point of the whole module: closing the session that forced the digit
|
||||
// must not renumber the one still on screen.
|
||||
const afterClose = assignChips([three[1], three[2]], map);
|
||||
check('surviving session keeps its digit', 'ds2', afterClose.get('b'));
|
||||
check('and so does the one after it', 'ds3', afterClose.get('c'));
|
||||
|
||||
// A label freed by a closed session may be handed to a genuinely new one.
|
||||
const reused = assignChips(
|
||||
[three[1], { sessionId: 'd', since: 400, base: 'dev-skills' }], afterClose);
|
||||
check('freed label is reused', 'ds', reused.get('d'));
|
||||
check('existing session still untouched', 'ds2', reused.get('b'));
|
||||
|
||||
// --- widths ----------------------------------------------------------------
|
||||
const many = Array.from({ length: 12 }, (_, i) =>
|
||||
({ sessionId: `s${i}`, since: i, base: 'umbar' }));
|
||||
const wide = assignChips(many);
|
||||
const lengths = [...new Set([...wide.values()].map(l => l.length))];
|
||||
check('every label fits in three characters', [3], lengths);
|
||||
check('twelve sessions in one project are all distinct',
|
||||
12, new Set(wide.values()).size);
|
||||
|
||||
out(failures ? `\n${failures} failure(s)` : '\nall passed');
|
||||
if (typeof imports !== 'undefined')
|
||||
imports.system.exit(failures ? 1 : 0);
|
||||
else if (failures)
|
||||
process.exit(1);
|
||||
Reference in New Issue
Block a user