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);