diff --git a/README.md b/README.md
index 2103a76..9afb32c 100644
--- a/README.md
+++ b/README.md
@@ -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`.
Чтобы посмотреть сырые события хуков, создайте файл-маркер, и каждое событие
будет дописываться в него:
diff --git a/extension.js b/extension.js
index 17f3ac1..74c0b26 100644
--- a/extension.js
+++ b/extension.js
@@ -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() {
diff --git a/lib/abbrev.js b/lib/abbrev.js
new file mode 100644
index 0000000..b0fec47
--- /dev/null
+++ b/lib/abbrev.js
@@ -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;
+}
diff --git a/lib/indicator.js b/lib/indicator.js
index 7199de3..f659d9a 100644
--- a/lib/indicator.js
+++ b/lib/indicator.js
@@ -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() {
diff --git a/prefs.js b/prefs.js
index ac1d205..76f5200 100644
--- a/prefs.js
+++ b/prefs.js
@@ -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.')));
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 1d1908f..837274d 100644
--- a/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml
+++ b/schemas/org.gnome.shell.extensions.claude-code-status.gschema.xml
@@ -4,8 +4,13 @@
path="/org/gnome/shell/extensions/claude-code-status/">
true
- Show the project name in the panel
- 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.
+ Label each chip with its project
+ 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.
+
+
+ true
+ 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.
true
diff --git a/stylesheet.css b/stylesheet.css
index 01b4d28..e517cef 100644
--- a/stylesheet.css
+++ b/stylesheet.css
@@ -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;
}
diff --git a/tests/test-abbrev.js b/tests/test-abbrev.js
new file mode 100644
index 0000000..cf8a525
--- /dev/null
+++ b/tests/test-abbrev.js
@@ -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);