@oddessentials/agent-guild 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url';
7
7
  import { ProviderRegistry } from './providers.mjs';
8
8
  import { SessionManager } from './session-manager.mjs';
9
9
  import { UsageMonitor } from './usage.mjs';
10
+ import { ModelStats } from './model-stats.mjs';
10
11
  import { createManagerServer } from './server.mjs';
11
12
  import { resolveBaseEnv, pathReader } from './shell-env.mjs';
12
13
  import { writeReportShims } from './report-shims.mjs';
@@ -52,6 +53,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
52
53
  let api;
53
54
  const manager = new SessionManager({ registry, baseEnv, getApiUrl: () => api.url, sessionDefaults, shimDir });
54
55
  const usage = new UsageMonitor({ registry, env: baseEnv });
56
+ const modelStats = new ModelStats({ registry });
55
57
  let closing = null;
56
58
 
57
59
  const versionTimer = setInterval(() => registry.refreshVersions().catch(() => {}), VERSION_REFRESH_MS);
@@ -79,6 +81,7 @@ export async function startManager({ port = resolvePort(), host = DEFAULT_HOST,
79
81
  manager,
80
82
  registry,
81
83
  usage,
84
+ modelStats,
82
85
  token,
83
86
  host,
84
87
  port,
@@ -0,0 +1,338 @@
1
+ import { toNumber } from './usage.mjs';
2
+
3
+ const CATALOG_URL = 'https://openrouter.ai/api/v1/models';
4
+ const CATALOG_TTL_MS = 6 * 60 * 60 * 1000;
5
+ const RETRY_MS = 10 * 60 * 1000;
6
+ const FETCH_TIMEOUT_MS = 20000;
7
+ const NEW_MS = 30 * 24 * 60 * 60 * 1000;
8
+ const ID_RE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?::[a-z0-9-]+)?$/;
9
+
10
+ const AA = 'Artificial Analysis';
11
+ const DA_MODELS = 'Design Arena · Models';
12
+ const DA_AGENTS = 'Design Arena · Agents';
13
+
14
+ const STATS = [
15
+ {
16
+ id: 'coding', label: 'Coding Index', short: 'Coding', group: AA, index: 'coding_index',
17
+ about: 'How well the model writes and fixes code, scored by Artificial Analysis on coding evaluations such as Terminal-Bench (real tasks in a terminal). When building: working code with fewer retries.',
18
+ },
19
+ {
20
+ id: 'intelligence', label: 'Intelligence Index', short: 'Intelligence', group: AA, index: 'intelligence_index',
21
+ about: 'Artificial Analysis\'s overall score across its evaluations of agentic work, coding, scientific reasoning and general knowledge. When building: understanding what you ask, planning changes, solving hard problems.',
22
+ },
23
+ {
24
+ id: 'agentic', label: 'Agentic Index', short: 'Agentic', group: AA, index: 'agentic_index',
25
+ about: 'How well the model completes multi-step tasks on its own, using tools until the job is done (Artificial Analysis). When building: longer agent runs that stay on track without your help.',
26
+ },
27
+ {
28
+ id: 'overall', label: 'Overall', group: DA_MODELS, arena: 'models', category: 'codecategories',
29
+ about: 'Design Arena\'s combined result for Website, UI Component, Game Dev, Data Visualization and 3D, judged by real users\' votes. When building: overall front-end quality.',
30
+ },
31
+ {
32
+ id: 'website', label: 'Website', group: DA_MODELS, arena: 'models', category: 'website',
33
+ about: 'Complete websites built as one HTML file from real users\' prompts; users vote for the better one. When building: landing pages and site layouts.',
34
+ },
35
+ {
36
+ id: 'uicomponent', label: 'UI Component', group: DA_MODELS, arena: 'models', category: 'uicomponent',
37
+ about: 'Focused interface components as one HTML file; users vote. When building: individual pieces of an interface.',
38
+ },
39
+ {
40
+ id: 'gamedev', label: 'Game Dev', group: DA_MODELS, arena: 'models', category: 'gamedev',
41
+ about: 'Playable browser games as one HTML file; users play them and vote. When building: interactive, game-like front ends.',
42
+ },
43
+ {
44
+ id: 'dataviz', label: 'Data Visualization', group: DA_MODELS, arena: 'models', category: 'dataviz',
45
+ about: 'Interactive visualizations of provided data as one HTML file; users vote. When building: charts and dashboards.',
46
+ },
47
+ {
48
+ id: '3d', label: '3D', group: DA_MODELS, arena: 'models', category: '3d',
49
+ about: 'Interactive 3D experiences in the browser as one HTML file; users try them and vote. When building: 3D scenes.',
50
+ },
51
+ {
52
+ id: 'svg', label: 'SVG', group: DA_MODELS, arena: 'models', category: 'svg',
53
+ about: 'Scalable vector graphics as SVG markup: icons, illustrations, diagrams. When building: graphics as code.',
54
+ },
55
+ {
56
+ id: 'webapps', label: 'Web Apps', group: DA_AGENTS, arena: 'agents', category: 'webapps',
57
+ about: 'Multi-file React front ends built by the model as an agent over many steps; users compare results. When building: the multi-file work a coding agent does.',
58
+ },
59
+ {
60
+ id: 'fullstack', label: 'Full Stack', group: DA_AGENTS, arena: 'agents', category: 'fullstack',
61
+ about: 'Complete React apps with sign-in, a database and a back end, built by the model as an agent. When building: whole applications, not just pages.',
62
+ },
63
+ {
64
+ id: 'mobileapps', label: 'Mobile Apps', group: DA_AGENTS, arena: 'agents', category: 'mobileapps',
65
+ about: 'Cross-platform React Native mobile apps built by the model as an agent; users compare them. When building: mobile apps.',
66
+ },
67
+ ];
68
+ const INDEXES = STATS.filter((stat) => stat.index).map((stat) => stat.id);
69
+
70
+ class CatalogError extends Error {}
71
+
72
+ const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
73
+ const newestFirst = (a, b) => (b.created ?? 0) - (a.created ?? 0) || compare(a.id, b.id);
74
+ const record = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : {});
75
+ const finite = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null);
76
+ const isMeasured = (model) => Object.keys(model.values).length > 0;
77
+
78
+ function text(value, max) {
79
+ return typeof value === 'string' && value.trim() ? value.trim().slice(0, max) : null;
80
+ }
81
+
82
+ function strings(value) {
83
+ return Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v).map((v) => v.slice(0, 40)) : [];
84
+ }
85
+
86
+ function perMillion(value) {
87
+ const n = toNumber(value);
88
+ return n === null || n < 0 ? null : Math.round(n * 1e10) / 1e4;
89
+ }
90
+
91
+ function displayName(name, id) {
92
+ const full = text(name, 120) ?? id;
93
+ const colon = full.indexOf(': ');
94
+ return colon > 0 && colon <= 40 && full.length > colon + 2 ? full.slice(colon + 2) : full;
95
+ }
96
+
97
+ function readValues(benchmarks) {
98
+ const analysis = record(benchmarks.artificial_analysis);
99
+ const arena = Array.isArray(benchmarks.design_arena) ? benchmarks.design_arena.map(record) : [];
100
+ const values = {};
101
+ for (const stat of STATS) {
102
+ if (stat.index) {
103
+ const value = finite(analysis[stat.index]);
104
+ if (value !== null) values[stat.id] = { value, rank: null };
105
+ continue;
106
+ }
107
+ const rows = arena.filter((row) => row.arena === stat.arena && row.category === stat.category && finite(row.elo) !== null);
108
+ if (rows.length === 1) values[stat.id] = { value: rows[0].elo, rank: finite(rows[0].rank) };
109
+ }
110
+ return values;
111
+ }
112
+
113
+ export function parseCatalog(payload) {
114
+ const data = Array.isArray(payload?.data) ? payload.data : [];
115
+ const listings = [];
116
+ for (const raw of data) {
117
+ const entry = record(raw);
118
+ const id = typeof entry.id === 'string' ? entry.id : '';
119
+ if (!ID_RE.test(id)) continue;
120
+ const architecture = record(entry.architecture);
121
+ const pricing = record(entry.pricing);
122
+ listings.push({
123
+ id,
124
+ slug: id.slice(id.indexOf('/') + 1),
125
+ canonical: text(entry.canonical_slug, 200) ?? '',
126
+ name: displayName(entry.name, id),
127
+ created: finite(entry.created),
128
+ context: finite(entry.context_length),
129
+ maxOutput: finite(record(entry.top_provider).max_completion_tokens),
130
+ input: strings(architecture.input_modalities),
131
+ text: strings(architecture.output_modalities).includes('text'),
132
+ tools: strings(entry.supported_parameters).includes('tools'),
133
+ reasoning: strings(record(entry.reasoning).supported_efforts),
134
+ price: { input: perMillion(pricing.prompt), output: perMillion(pricing.completion) },
135
+ expires: text(entry.expiration_date, 40),
136
+ values: readValues(record(entry.benchmarks)),
137
+ });
138
+ }
139
+ return listings;
140
+ }
141
+
142
+ function signature(listing) {
143
+ return JSON.stringify(STATS.map((stat) => listing.values[stat.id] ?? null));
144
+ }
145
+
146
+ export function indexCatalog(listings) {
147
+ const byId = new Map();
148
+ for (const listing of listings) byId.set(listing.id, [...(byId.get(listing.id) ?? []), listing]);
149
+ const groups = new Map();
150
+ for (const copies of byId.values()) {
151
+ if (new Set(copies.map(signature)).size > 1) continue;
152
+ const key = copies[0].canonical || copies[0].id;
153
+ groups.set(key, [...(groups.get(key) ?? []), copies[0]]);
154
+ }
155
+ const models = new Map();
156
+ const aliasOf = new Map();
157
+ for (const group of groups.values()) {
158
+ const sets = new Set(group.map(signature)).size === 1 ? [group] : group.map((listing) => [listing]);
159
+ for (const set of sets) {
160
+ const [main] = [...set].sort((a, b) => a.id.includes(':') - b.id.includes(':') || compare(a.id, b.id));
161
+ models.set(main.id, main);
162
+ for (const listing of set) aliasOf.set(listing.id, main.id);
163
+ }
164
+ }
165
+ const bySlug = new Map();
166
+ for (const [id, main] of aliasOf) {
167
+ const slug = id.slice(id.indexOf('/') + 1);
168
+ bySlug.set(slug, bySlug.has(slug) && bySlug.get(slug) !== main ? null : main);
169
+ }
170
+ return { models, aliasOf, bySlug };
171
+ }
172
+
173
+ export function tierFor(level) {
174
+ if (level >= 90) return 'S';
175
+ if (level >= 75) return 'A';
176
+ if (level >= 50) return 'B';
177
+ if (level >= 25) return 'C';
178
+ return 'D';
179
+ }
180
+
181
+ export function standing(value, others) {
182
+ if (others.length === 0) return null;
183
+ let above = 0;
184
+ let tied = 0;
185
+ for (const other of others) {
186
+ if (other > value) above++;
187
+ else if (other === value) tied++;
188
+ }
189
+ const of = others.length + 1;
190
+ const level = Math.round((100 * (of - 1 - above - tied / 2)) / (of - 1));
191
+ return { level, tier: tierFor(level), place: 1 + above, tied: tied > 0, of };
192
+ }
193
+
194
+ function patternFor(provider) {
195
+ if (!provider.modelPattern) return null;
196
+ try {
197
+ return new RegExp(`^(?:${provider.modelPattern})$`, 'i');
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+
203
+ export function providerModels(index, provider) {
204
+ const pattern = patternFor(provider);
205
+ if (!pattern) return [];
206
+ return [...index.models.values()].filter((model) => model.text && model.tools && pattern.test(model.slug)).sort(newestFirst);
207
+ }
208
+
209
+ export function modelNames(raw) {
210
+ const exact = String(raw ?? '').trim().toLowerCase()
211
+ .replace(/\[[^\]]*\]$/, '')
212
+ .trim()
213
+ .replace(/\s+/g, '-');
214
+ if (!exact) return [];
215
+ const name = exact.replace(/:[a-z0-9-]+$/, '');
216
+ const undated = name.replace(/(?:-\d{8}|-\d{4}-\d{2}-\d{2}|@\d{8})$/, '');
217
+ const dotted = undated.replace(/(\d+)-(\d+)(?=$|-)/g, '$1.$2');
218
+ return [...new Set([exact, name, undated, dotted])];
219
+ }
220
+
221
+ export function resolveModel(index, related, model) {
222
+ for (const raw of [model?.name, model?.displayName]) {
223
+ const names = modelNames(raw);
224
+ for (const name of names) {
225
+ const id = name.includes('/') ? index.aliasOf.get(name) : index.bySlug.get(name);
226
+ if (id) return id;
227
+ }
228
+ const last = names.at(-1);
229
+ if (!last || !/[a-z]/.test(last)) continue;
230
+ const hits = related.filter((m) => m.slug.slice(m.slug.indexOf('-') + 1) === last);
231
+ if (hits.length === 1) return hits[0].id;
232
+ }
233
+ return null;
234
+ }
235
+
236
+ function cardFor(model, pool, retrievedAt) {
237
+ const stats = {};
238
+ for (const stat of STATS) {
239
+ const own = model.values[stat.id];
240
+ if (!own) continue;
241
+ const others = pool[stat.id].filter((entry) => entry.id !== model.id).map((entry) => entry.value);
242
+ const place = standing(own.value, others) ?? { level: null, tier: null, place: null, tied: false, of: null };
243
+ stats[stat.id] = { ...place, value: own.value, rank: own.rank };
244
+ }
245
+ const created = model.created === null ? null : model.created * 1000;
246
+ return {
247
+ id: model.id,
248
+ name: model.name,
249
+ created: created === null ? null : new Date(created).toISOString(),
250
+ new: created !== null && Date.parse(retrievedAt) - created < NEW_MS,
251
+ expires: model.expires,
252
+ context: model.context,
253
+ maxOutput: model.maxOutput,
254
+ input: model.input,
255
+ reasoning: model.reasoning,
256
+ price: model.price,
257
+ stats,
258
+ };
259
+ }
260
+
261
+ export function describeCatalog({ index, retrievedAt, stale, error }, providers, sessions = []) {
262
+ const stats = STATS.map((stat) => ({ id: stat.id, label: stat.label, short: stat.short ?? stat.label, group: stat.group, about: stat.about }));
263
+ const result = { retrievedAt, stale, error, stats, pool: null, providers: {}, models: {}, sessions: {} };
264
+ if (!index) return result;
265
+ const related = new Map(providers.map((provider) => [provider.id, providerModels(index, provider)]));
266
+ const listed = new Map([...related].map(([id, models]) => [id, models.filter(isMeasured)]));
267
+ const members = new Map();
268
+ for (const models of listed.values()) for (const model of models) members.set(model.id, model);
269
+ const pool = {};
270
+ for (const stat of STATS) {
271
+ pool[stat.id] = [...members.values()].filter((m) => m.values[stat.id]).map((m) => ({ id: m.id, value: m.values[stat.id].value }));
272
+ }
273
+ result.pool = { tools: providers.filter((p) => listed.get(p.id).length).map((p) => p.tool) };
274
+ const card = (id) => (result.models[id] ??= cardFor(index.models.get(id), pool, retrievedAt));
275
+ for (const provider of providers) {
276
+ const models = listed.get(provider.id);
277
+ if (models.length === 0) continue;
278
+ const featured = models.find((m) => INDEXES.every((stat) => m.values[stat])) ?? models[0];
279
+ result.providers[provider.id] = { featured: featured.id, models: models.map((m) => card(m.id).id) };
280
+ }
281
+ for (const session of sessions) {
282
+ if (!session.model) continue;
283
+ const id = resolveModel(index, related.get(session.provider?.id) ?? [], session.model);
284
+ result.sessions[session.id] = id;
285
+ if (id) card(id);
286
+ }
287
+ return result;
288
+ }
289
+
290
+ function failure(err) {
291
+ if (err instanceof CatalogError) return err.message;
292
+ if (err?.name === 'TimeoutError') return `OpenRouter did not answer within ${FETCH_TIMEOUT_MS / 1000} seconds`;
293
+ if (err instanceof SyntaxError) return 'OpenRouter sent a model list that could not be read';
294
+ return `OpenRouter could not be reached (${err?.cause?.code || err?.message || 'unknown error'})`;
295
+ }
296
+
297
+ export class ModelStats {
298
+ constructor({ registry, fetchImpl = fetch, ttlMs = CATALOG_TTL_MS, retryMs = RETRY_MS } = {}) {
299
+ this.registry = registry;
300
+ this.fetchImpl = fetchImpl;
301
+ this.ttlMs = ttlMs;
302
+ this.retryMs = retryMs;
303
+ this.current = null;
304
+ this.inflight = null;
305
+ }
306
+
307
+ async snapshot(sessions = []) {
308
+ return describeCatalog(await this.catalog(), this.registry.providers, sessions);
309
+ }
310
+
311
+ catalog() {
312
+ if (this.inflight) return this.inflight;
313
+ const entry = this.current;
314
+ if (entry && Date.now() - entry.at < (entry.catalog.error ? this.retryMs : this.ttlMs)) return Promise.resolve(entry.catalog);
315
+ this.inflight = this._fetch().then((catalog) => {
316
+ this.current = { catalog, at: Date.now() };
317
+ return catalog;
318
+ }).finally(() => { this.inflight = null; });
319
+ return this.inflight;
320
+ }
321
+
322
+ async _fetch() {
323
+ try {
324
+ const res = await this.fetchImpl(CATALOG_URL, {
325
+ headers: { Accept: 'application/json', 'User-Agent': 'agent-guild' },
326
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
327
+ });
328
+ if (!res.ok) throw new CatalogError(`OpenRouter answered HTTP ${res.status}`);
329
+ const listings = parseCatalog(await res.json());
330
+ if (listings.length === 0) throw new CatalogError('OpenRouter sent an empty model list');
331
+ return { index: indexCatalog(listings), retrievedAt: new Date().toISOString(), stale: false, error: null };
332
+ } catch (err) {
333
+ const last = this.current?.catalog;
334
+ if (last?.index) return { ...last, stale: true, error: failure(err) };
335
+ return { index: null, retrievedAt: null, stale: false, error: failure(err) };
336
+ }
337
+ }
338
+ }
@@ -100,6 +100,7 @@ export function createManagerServer({
100
100
  manager,
101
101
  registry,
102
102
  usage,
103
+ modelStats,
103
104
  token,
104
105
  host = '127.0.0.1',
105
106
  port = 0,
@@ -218,6 +219,9 @@ export function createManagerServer({
218
219
  if (route === '/usage' && method === 'GET') {
219
220
  return sendJson(res, 200, { usage: await usage.all() });
220
221
  }
222
+ if (route === '/model-stats' && method === 'GET') {
223
+ return sendJson(res, 200, await modelStats.snapshot(manager.list()));
224
+ }
221
225
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
222
226
  if (installMatch && method === 'POST') {
223
227
  const body = await readJsonBody(req);
package/web/app.js CHANGED
@@ -10,6 +10,8 @@ const state = {
10
10
  token: null,
11
11
  providers: [],
12
12
  usage: new Map(),
13
+ stats: null,
14
+ statsFor: new Map(),
13
15
  sessions: new Map(),
14
16
  views: new Map(),
15
17
  activeId: null,
@@ -185,6 +187,7 @@ function renderProviders() {
185
187
  renderCopies(node.querySelector('.copies'), provider);
186
188
  renderConsoleLinks(node, provider);
187
189
  renderUsage(node, provider);
190
+ renderModelStats(node, provider);
188
191
  return node;
189
192
  }));
190
193
  }
@@ -317,6 +320,440 @@ async function loadUsage() {
317
320
  }
318
321
  }
319
322
 
323
+ const ARTIFICIAL_ANALYSIS = 'Artificial Analysis';
324
+ const MODELS_SHOWN = 8;
325
+ const modelsView = { providerId: null, sessionId: null, focus: null, all: false, expanded: new Set() };
326
+ let modelsOpener = null;
327
+ let statsLoading = null;
328
+ let statsAgain = false;
329
+ let statsTimer;
330
+
331
+ function modelKey(s) {
332
+ return s.model ? `${s.model.name}\n${s.model.displayName ?? ''}` : '';
333
+ }
334
+
335
+ function sessionModelId(s) {
336
+ return state.statsFor.get(s.id) === modelKey(s) ? state.stats?.sessions[s.id] ?? null : null;
337
+ }
338
+
339
+ function loadStats() {
340
+ if (statsLoading) {
341
+ statsAgain = true;
342
+ return statsLoading;
343
+ }
344
+ const asked = new Map([...state.sessions.values()].map((s) => [s.id, modelKey(s)]));
345
+ statsLoading = api('GET', '/model-stats').then((stats) => {
346
+ state.stats = stats;
347
+ state.statsFor = asked;
348
+ for (const card of $('providers').children) {
349
+ const provider = state.providers.find((p) => p.id === card.dataset.id);
350
+ if (provider) renderModelStats(card, provider);
351
+ }
352
+ renderSessions();
353
+ if ($('models').open) {
354
+ const body = document.querySelector('.models-body');
355
+ const top = body.scrollTop;
356
+ const focus = modelsFocus();
357
+ const withTip = Boolean(tipFor?.closest('#models-list'));
358
+ renderModels();
359
+ body.scrollTop = top;
360
+ restoreModelsFocus(focus, withTip);
361
+ }
362
+ if (tipFor && !tipFor.isConnected) hideTip();
363
+ }, () => {}).finally(() => {
364
+ statsLoading = null;
365
+ if (statsAgain) {
366
+ statsAgain = false;
367
+ loadStats();
368
+ }
369
+ });
370
+ return statsLoading;
371
+ }
372
+
373
+ function scheduleStats() {
374
+ clearTimeout(statsTimer);
375
+ statsTimer = setTimeout(loadStats, 300);
376
+ }
377
+
378
+ function indexStats() {
379
+ return state.stats?.stats.filter((stat) => stat.group === ARTIFICIAL_ANALYSIS) ?? [];
380
+ }
381
+
382
+ function ordinal(n) {
383
+ const suffixes = ['th', 'st', 'nd', 'rd'];
384
+ const v = n % 100;
385
+ return `${n}${suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]}`;
386
+ }
387
+
388
+ function listJoin(items) {
389
+ return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
390
+ }
391
+
392
+ function formatTokens(n) {
393
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return '—';
394
+ if (n >= 1e6) return `${Number((n / 1e6).toFixed(2))}M`;
395
+ if (n >= 1e3) return `${Math.round(n / 1e3)}K`;
396
+ return String(n);
397
+ }
398
+
399
+ function formatPrice(n) {
400
+ return `$${Number(n.toFixed(n < 1 ? 3 : 2))}`;
401
+ }
402
+
403
+ function formatDate(iso, options = { year: 'numeric', month: 'short', day: 'numeric' }) {
404
+ const date = new Date(iso);
405
+ return Number.isNaN(date.getTime()) ? '' : date.toLocaleDateString(undefined, options);
406
+ }
407
+
408
+ function statValue(stat, entry) {
409
+ return stat.group === ARTIFICIAL_ANALYSIS ? String(entry.value) : `Elo ${entry.value}`;
410
+ }
411
+
412
+ function statPlace(entry) {
413
+ return `${entry.tied ? 'tied ' : ''}${ordinal(entry.place)} of ${entry.of}`;
414
+ }
415
+
416
+ function statSummary(stat, entry) {
417
+ if (!entry) return `${stat.label}: no published result`;
418
+ const value = `${stat.label} ${statValue(stat, entry)}`;
419
+ if (entry.level === null) return `${value}: no other model has this result to compare with`;
420
+ const rank = entry.rank === null ? '' : `, Design Arena rank ${entry.rank}`;
421
+ return `${value}: level ${entry.level}, tier ${entry.tier}, ${statPlace(entry)}${rank}`;
422
+ }
423
+
424
+ function levelValue(entry) {
425
+ if (!entry) return ['—'];
426
+ if (entry.level === null) return [String(entry.value)];
427
+ const tier = document.createElement('span');
428
+ tier.className = 'stat-tier';
429
+ tier.textContent = entry.tier;
430
+ const level = document.createElement('span');
431
+ level.className = 'level';
432
+ level.textContent = entry.level;
433
+ return [tier, level];
434
+ }
435
+
436
+ function tierClass(entry) {
437
+ return entry?.tier ? `tier-${entry.tier.toLowerCase()}` : '';
438
+ }
439
+
440
+ function statRow(stat, card, { detail = false } = {}) {
441
+ const node = $('stat-template').content.firstElementChild.cloneNode(true);
442
+ const entry = card.stats[stat.id];
443
+ node.querySelector('.stat-name').textContent = detail ? stat.label : stat.short;
444
+ const info = node.querySelector('.info');
445
+ info.dataset.stat = stat.id;
446
+ info.setAttribute('aria-label', `${stat.label}: ${stat.about}`);
447
+ const reading = node.querySelector('.stat-reading');
448
+ reading.querySelector('.stat-value').replaceChildren(...levelValue(entry));
449
+ node.classList.toggle('unmeasured', !entry);
450
+ if (entry?.tier) {
451
+ node.classList.add(tierClass(entry));
452
+ reading.querySelector('.stat-fill').style.width = `${entry.level}%`;
453
+ }
454
+ if (detail) {
455
+ const rank = entry?.rank == null ? '' : ` · Rank ${entry.rank}`;
456
+ reading.querySelector('.stat-note').textContent = !entry ? 'No published result'
457
+ : entry.level === null ? statValue(stat, entry) : `${statPlace(entry)} · ${statValue(stat, entry)}${rank}`;
458
+ }
459
+ reading.title = statSummary(stat, entry);
460
+ reading.setAttribute('aria-label', reading.title);
461
+ return node;
462
+ }
463
+
464
+ let tipFor = null;
465
+ let quietFocus = false;
466
+
467
+ function showTip(button) {
468
+ const stat = state.stats?.stats.find((s) => s.id === button.dataset.stat);
469
+ if (!stat) return;
470
+ const tip = $('tip');
471
+ $('tip-title').textContent = stat.label;
472
+ $('tip-source').textContent = stat.group;
473
+ $('tip-text').textContent = stat.about;
474
+ const host = $('models').open ? $('models') : document.body;
475
+ if (tip.parentElement !== host) host.append(tip);
476
+ tip.hidden = false;
477
+ const box = button.getBoundingClientRect();
478
+ const left = Math.min(Math.max(8, box.left + box.width / 2 - tip.offsetWidth / 2), innerWidth - tip.offsetWidth - 8);
479
+ const below = box.bottom + 8 + tip.offsetHeight <= innerHeight;
480
+ tip.style.left = `${Math.round(left)}px`;
481
+ tip.style.top = `${Math.round(below ? box.bottom + 8 : box.top - tip.offsetHeight - 8)}px`;
482
+ tipFor = button;
483
+ }
484
+
485
+ function hideTip() {
486
+ $('tip').hidden = true;
487
+ tipFor = null;
488
+ }
489
+
490
+ function renderModelStats(card, provider) {
491
+ const host = card.querySelector('.model-stats');
492
+ const stats = state.stats;
493
+ const entry = stats?.providers[provider.id];
494
+ if (!entry) {
495
+ if (!stats?.error || !provider.modelPattern) return host.replaceChildren();
496
+ const note = document.createElement('div');
497
+ note.className = 'usage-note';
498
+ note.textContent = `Benchmarks: ${stats.error}`;
499
+ note.title = note.textContent;
500
+ return host.replaceChildren(note);
501
+ }
502
+ const featured = stats.models[entry.featured];
503
+ const count = entry.models.length;
504
+ const complete = indexStats().every((stat) => featured.stats[stat.id]);
505
+ const head = document.createElement('button');
506
+ head.type = 'button';
507
+ head.className = 'model-stats-head';
508
+ head.textContent = `${featured.name} · ${count} model${count === 1 ? '' : 's'}`;
509
+ head.setAttribute('aria-label', head.textContent);
510
+ head.title = `${featured.name} is the newest ${provider.tool} model with ${complete ? 'all three Artificial Analysis indexes' : 'published benchmarks'}. Open to compare ${count === 1 ? 'it' : `all ${count}`}.`;
511
+ head.addEventListener('click', () => showModels({ providerId: provider.id, focus: featured.id }));
512
+ host.replaceChildren(head, ...indexStats().map((stat) => statRow(stat, featured)));
513
+ }
514
+
515
+ function modelStatsLine(s) {
516
+ const id = sessionModelId(s);
517
+ const card = id ? state.stats.models[id] : null;
518
+ if (!card) return '';
519
+ return indexStats().map((stat) => {
520
+ const entry = card.stats[stat.id];
521
+ return `${stat.short} ${entry?.tier ? `${entry.tier} ${entry.level}` : '—'}`;
522
+ }).join(' · ');
523
+ }
524
+
525
+ function modelsFocus() {
526
+ const active = document.activeElement;
527
+ const row = active?.closest?.('#models-list .model');
528
+ return row ? { id: row.dataset.id, stat: active.dataset.stat ?? null } : null;
529
+ }
530
+
531
+ function restoreModelsFocus(focus, withTip) {
532
+ const row = focus && [...$('models-list').children].find((el) => el.dataset.id === focus.id);
533
+ if (!row) return;
534
+ const info = focus.stat && row.querySelector(`.info[data-stat="${focus.stat}"]`);
535
+ quietFocus = true;
536
+ (info || row.querySelector('.model-toggle')).focus({ preventScroll: true });
537
+ quietFocus = false;
538
+ if (withTip && info) showTip(info);
539
+ }
540
+
541
+ function showModels({ providerId = null, sessionId = null, focus = null }) {
542
+ Object.assign(modelsView, { providerId, sessionId, focus, all: false, expanded: new Set(focus ? [focus] : []) });
543
+ const dialog = $('models');
544
+ renderModels();
545
+ if (!dialog.open) {
546
+ modelsOpener = document.activeElement;
547
+ dialog.showModal();
548
+ }
549
+ const toggle = $('models-list').querySelector('[aria-expanded="true"]');
550
+ toggle?.focus();
551
+ toggle?.scrollIntoView({ block: 'nearest' });
552
+ }
553
+
554
+ function openSessionModel(id) {
555
+ const s = state.sessions.get(id);
556
+ if (!s?.model) return;
557
+ if (!state.stats || state.statsFor.get(id) !== modelKey(s)) scheduleStats();
558
+ showModels({ sessionId: id, focus: sessionModelId(s) });
559
+ }
560
+
561
+ function unmatchedText(s) {
562
+ const stats = state.stats;
563
+ if (!stats) return 'Loading benchmarks from OpenRouter…';
564
+ if (!stats.pool) return `Benchmarks are unavailable: ${stats.error}.`;
565
+ if (!(s.id in stats.sessions) || state.statsFor.get(s.id) !== modelKey(s)) return 'Looking this model up in OpenRouter’s catalog…';
566
+ const names = [s.model.name, s.model.displayName].filter((name, i, all) => name && all.indexOf(name) === i);
567
+ return `OpenRouter’s catalog has no model named ${names.map((name) => `“${name}”`).join(' or ')}.`;
568
+ }
569
+
570
+ function sourceText(stats, { levels = true } = {}) {
571
+ if (!stats?.retrievedAt) return '';
572
+ const parts = [`Benchmarks from Artificial Analysis and Design Arena via OpenRouter, fetched ${relativeTime(stats.retrievedAt)}.`];
573
+ if (stats.stale) parts.push(`The last refresh failed (${stats.error}), so these results may be out of date.`);
574
+ if (stats.pool && levels) {
575
+ parts.push('Artificial Analysis results are index scores. Design Arena results are Elo ratings from real users\' head-to-head votes, and Rank is the model\'s place on Design Arena\'s own leaderboard.');
576
+ parts.push(`Level 0–100 is a model's standing on each benchmark among the models ${listJoin(stats.pool.tools)} run; 100 is the best result.`);
577
+ parts.push('Tier: S 90+, A 75+, B 50+, C 25+, D below 25.');
578
+ }
579
+ return parts.join(' ');
580
+ }
581
+
582
+ function usedBy(id) {
583
+ return [...state.sessions.values()]
584
+ .filter((s) => s.status === 'running' && sessionModelId(s) === id)
585
+ .map((s) => s.name);
586
+ }
587
+
588
+ function badge(kind, label, title) {
589
+ const el = document.createElement('span');
590
+ el.className = `badge ${kind}`;
591
+ el.textContent = label;
592
+ el.title = title;
593
+ return el;
594
+ }
595
+
596
+ function modelFacts(card) {
597
+ const list = document.createElement('dl');
598
+ list.className = 'model-facts';
599
+ const fact = (term, value) => {
600
+ if (!value) return;
601
+ const row = document.createElement('div');
602
+ const dt = document.createElement('dt');
603
+ const dd = document.createElement('dd');
604
+ dt.textContent = term;
605
+ dd.textContent = value;
606
+ row.append(dt, dd);
607
+ list.append(row);
608
+ };
609
+ fact('Context', card.context ? `${formatTokens(card.context)} tokens` : '');
610
+ fact('Max output', card.maxOutput ? `${formatTokens(card.maxOutput)} tokens` : '');
611
+ fact('Input', card.input.join(', '));
612
+ fact('Reasoning effort', card.reasoning.join(', '));
613
+ if (card.price.input !== null && card.price.output !== null) {
614
+ fact('Price', `${formatPrice(card.price.input)} input · ${formatPrice(card.price.output)} output per 1M tokens`);
615
+ }
616
+ fact('Created', card.created ? formatDate(card.created) : '');
617
+ fact('Expires', card.expires ? formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' }) : '');
618
+ const link = document.createElement('a');
619
+ link.className = 'console-link';
620
+ link.href = httpsHref(`https://openrouter.ai/${card.id}`) ?? '';
621
+ link.target = '_blank';
622
+ link.rel = 'noopener noreferrer';
623
+ link.textContent = 'OpenRouter ↗';
624
+ link.setAttribute('aria-label', `${card.name} on OpenRouter (opens in a new tab)`);
625
+ const facts = document.createElement('div');
626
+ facts.className = 'model-more';
627
+ facts.append(list, link);
628
+ return facts;
629
+ }
630
+
631
+ function modelDetail(card) {
632
+ const groups = [];
633
+ for (const stat of state.stats.stats) {
634
+ if (groups.at(-1)?.name !== stat.group) groups.push({ name: stat.group, stats: [] });
635
+ groups.at(-1).stats.push(stat);
636
+ }
637
+ const sections = groups.map(({ name, stats }) => {
638
+ const section = document.createElement('div');
639
+ section.className = 'model-group';
640
+ const heading = document.createElement('h4');
641
+ heading.textContent = name;
642
+ section.append(heading);
643
+ if (stats.some((stat) => card.stats[stat.id])) {
644
+ section.append(...stats.map((stat) => statRow(stat, card, { detail: true })));
645
+ } else {
646
+ const none = document.createElement('p');
647
+ none.className = 'model-none';
648
+ none.textContent = 'No published results';
649
+ section.append(none);
650
+ }
651
+ return section;
652
+ });
653
+ return [...sections, modelFacts(card)];
654
+ }
655
+
656
+ function modelRow(card) {
657
+ const node = $('model-template').content.firstElementChild.cloneNode(true);
658
+ node.dataset.id = card.id;
659
+ const toggle = node.querySelector('.model-toggle');
660
+ const detail = node.querySelector('.model-detail');
661
+ node.querySelector('.model-name').textContent = card.name;
662
+ const users = usedBy(card.id);
663
+ const badges = [];
664
+ if (users.length) badges.push(badge('in-use', 'In use', `Used by ${listJoin(users)}`));
665
+ if (card.new) badges.push(badge('new', 'New', `Created ${formatDate(card.created)}`));
666
+ if (card.expires) {
667
+ const day = formatDate(card.expires, { month: 'short', day: 'numeric', timeZone: 'UTC' });
668
+ badges.push(badge('expires', `Expires ${day}`, `OpenRouter lists this model as expiring on ${formatDate(card.expires, { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC' })}`));
669
+ }
670
+ node.querySelector('.model-title').append(...badges);
671
+ for (const stat of indexStats()) {
672
+ const entry = card.stats[stat.id];
673
+ const cell = document.createElement('span');
674
+ cell.className = ['model-index', tierClass(entry), entry ? '' : 'unmeasured'].filter(Boolean).join(' ');
675
+ cell.replaceChildren(...levelValue(entry));
676
+ cell.title = statSummary(stat, entry);
677
+ toggle.append(cell);
678
+ }
679
+ const context = document.createElement('span');
680
+ context.className = 'model-context';
681
+ context.textContent = formatTokens(card.context);
682
+ context.title = card.context ? `Context: ${card.context.toLocaleString()} tokens` : 'Context: not listed';
683
+ toggle.append(context);
684
+ const summary = indexStats().map((stat) => statSummary(stat, card.stats[stat.id])).join('. ');
685
+ toggle.setAttribute('aria-description', summary);
686
+ const expand = (open) => {
687
+ toggle.setAttribute('aria-expanded', String(open));
688
+ if (open && !detail.childElementCount) detail.replaceChildren(...modelDetail(card));
689
+ detail.hidden = !open;
690
+ };
691
+ expand(modelsView.expanded.has(card.id));
692
+ toggle.addEventListener('click', () => {
693
+ const open = toggle.getAttribute('aria-expanded') !== 'true';
694
+ if (open) modelsView.expanded.add(card.id);
695
+ else modelsView.expanded.delete(card.id);
696
+ expand(open);
697
+ });
698
+ return node;
699
+ }
700
+
701
+ function renderModels() {
702
+ const stats = state.stats;
703
+ const session = modelsView.sessionId ? state.sessions.get(modelsView.sessionId) : null;
704
+ const provider = session?.provider ?? state.providers.find((p) => p.id === modelsView.providerId);
705
+ if (!provider || (modelsView.sessionId && !session?.model)) return $('models').close();
706
+ paintProviderIcon($('models-icon'), provider);
707
+ const list = stats?.providers[provider.id]?.models ?? [];
708
+ let ids = list;
709
+ let title = `${provider.tool} models`;
710
+ let note = '';
711
+ if (session) {
712
+ const id = sessionModelId(session);
713
+ if (id && !modelsView.focus) {
714
+ modelsView.focus = id;
715
+ modelsView.expanded.add(id);
716
+ }
717
+ if (!id) {
718
+ ids = [];
719
+ title = `${provider.tool} · ${modelText(session)}`;
720
+ note = unmatchedText(session);
721
+ } else if (!list.includes(id)) {
722
+ ids = [id];
723
+ title = `${provider.tool} · ${stats.models[id].name}`;
724
+ }
725
+ } else if (!stats) {
726
+ note = 'Loading benchmarks from OpenRouter…';
727
+ } else if (list.length === 0) {
728
+ note = stats.error ? `Benchmarks are unavailable: ${stats.error}.` : `OpenRouter lists no benchmarked ${provider.tool} models.`;
729
+ }
730
+ $('models-title').textContent = title;
731
+ $('models-sub').textContent = ids.length > 1
732
+ ? `${ids.length} models with published benchmarks, newest first`
733
+ : session ? `Reported by ${session.name} as “${session.model.name}”` : '';
734
+ const all = modelsView.all || ids.length <= MODELS_SHOWN || ids.indexOf(modelsView.focus) >= MODELS_SHOWN;
735
+ const shown = all ? ids : ids.slice(0, MODELS_SHOWN);
736
+ const columns = $('models-columns');
737
+ columns.hidden = shown.length === 0;
738
+ columns.replaceChildren(...['Model', ...indexStats().map((stat) => stat.short), 'Context'].map((label) => {
739
+ const span = document.createElement('span');
740
+ span.textContent = label;
741
+ return span;
742
+ }));
743
+ $('models-list').replaceChildren(...shown.map((id) => modelRow(stats.models[id])));
744
+ const more = $('models-more');
745
+ more.hidden = all;
746
+ more.textContent = `Show ${ids.length - shown.length} older models`;
747
+ $('models-note').textContent = note;
748
+ $('models-note').hidden = !note;
749
+ $('models-source').textContent = sourceText(stats, { levels: shown.length > 0 });
750
+ $('models-source').hidden = !$('models-source').textContent;
751
+ }
752
+
753
+ function closeModels() {
754
+ if ($('models').open) $('models').close();
755
+ }
756
+
320
757
  const CHANNEL_LABELS = {
321
758
  npm: 'npm', native: 'native', brew: 'Homebrew', winget: 'WinGet', legacy: 'legacy install', unknown: 'unknown install',
322
759
  };
@@ -433,6 +870,7 @@ function buildCard(session) {
433
870
  node.querySelector('.stop').addEventListener('click', () => stopSession(session.id));
434
871
  node.querySelector('.remove').addEventListener('click', () => removeSession(session.id));
435
872
  node.querySelector('.rename').addEventListener('click', () => renameSession(session.id));
873
+ node.querySelector('.model-pill').addEventListener('click', () => openSessionModel(session.id));
436
874
  return node;
437
875
  }
438
876
 
@@ -460,7 +898,8 @@ function updateCard(node, s) {
460
898
  const model = node.querySelector('.model-pill');
461
899
  model.hidden = !s.model;
462
900
  model.textContent = modelText(s);
463
- model.title = modelTitle(s);
901
+ model.title = [modelTitle(s), modelStatsLine(s)].filter(Boolean).join('\n');
902
+ model.setAttribute('aria-label', `Benchmarks for ${modelText(s)}`);
464
903
  model.className = `model-pill ${s.model?.source || ''}`;
465
904
  const cwd = node.querySelector('.cwd-line');
466
905
  // The LRM keeps a leading "/" in place under the right-to-left truncation style.
@@ -492,6 +931,7 @@ function renderSessions() {
492
931
  $('session-count').textContent = sessions.length ? `· ${running} running` : '';
493
932
  $('empty').hidden = sessions.length > 0;
494
933
  if (state.activeId) updatePanel();
934
+ if (state.stats && sessions.some((s) => s.model && state.statsFor.get(s.id) !== modelKey(s))) scheduleStats();
495
935
  }
496
936
 
497
937
  function upsertSession(session) {
@@ -749,6 +1189,7 @@ function enterStopping(running = 0) {
749
1189
  state.stopping = true;
750
1190
  state.stopRemaining = null;
751
1191
  closePanel();
1192
+ closeModels();
752
1193
  for (const view of state.views.values()) view.dispose();
753
1194
  state.views.clear();
754
1195
  state.sessions.clear();
@@ -818,6 +1259,7 @@ function connectEvents() {
818
1259
  } else if (msg.type === 'providers.updated') {
819
1260
  state.providers = msg.providers;
820
1261
  renderProviders();
1262
+ scheduleStats();
821
1263
  }
822
1264
  };
823
1265
  ws.onclose = () => {
@@ -854,8 +1296,10 @@ function readTokenFromHash() {
854
1296
  }
855
1297
 
856
1298
  let usageTimer;
1299
+ let statsInterval;
857
1300
 
858
1301
  function showAuth(message = '') {
1302
+ closeModels();
859
1303
  $('app').hidden = true;
860
1304
  $('terminal-panel').hidden = true;
861
1305
  $('stopped').hidden = true;
@@ -885,6 +1329,9 @@ async function boot() {
885
1329
  loadUsage();
886
1330
  clearInterval(usageTimer);
887
1331
  usageTimer = setInterval(loadUsage, 60000);
1332
+ loadStats();
1333
+ clearInterval(statsInterval);
1334
+ statsInterval = setInterval(loadStats, 60 * 60 * 1000);
888
1335
  }
889
1336
 
890
1337
  $('auth-form').addEventListener('submit', (e) => {
@@ -895,6 +1342,44 @@ $('auth-form').addEventListener('submit', (e) => {
895
1342
  boot();
896
1343
  });
897
1344
  $('panel-close').addEventListener('click', closePanel);
1345
+ $('models-close').addEventListener('click', closeModels);
1346
+ $('models').addEventListener('click', (e) => { if (e.target === $('models')) closeModels(); });
1347
+ $('models').addEventListener('close', () => {
1348
+ hideTip();
1349
+ const opener = modelsOpener?.isConnected ? modelsOpener
1350
+ : modelsView.sessionId ? cards.get(modelsView.sessionId)?.querySelector('.model-pill')
1351
+ : $('providers').querySelector(`.provider[data-id="${modelsView.providerId}"] .model-stats-head`);
1352
+ opener?.focus();
1353
+ modelsOpener = null;
1354
+ });
1355
+ document.addEventListener('pointerover', (e) => {
1356
+ const info = e.target.closest?.('.info');
1357
+ if (info && e.pointerType !== 'touch') showTip(info);
1358
+ });
1359
+ document.addEventListener('pointerout', (e) => {
1360
+ if (tipFor && e.target === tipFor && document.activeElement !== tipFor) hideTip();
1361
+ });
1362
+ document.addEventListener('pointerdown', (e) => { if (tipFor && !e.target.closest?.('.info')) hideTip(); });
1363
+ document.addEventListener('click', (e) => {
1364
+ const info = e.target.closest?.('.info');
1365
+ if (info) showTip(info);
1366
+ });
1367
+ document.addEventListener('focusin', (e) => { if (!quietFocus && e.target.matches?.('.info')) showTip(e.target); });
1368
+ document.addEventListener('focusout', (e) => { if (e.target === tipFor) hideTip(); });
1369
+ document.addEventListener('keydown', (e) => {
1370
+ if (e.key !== 'Escape' || !tipFor) return;
1371
+ hideTip();
1372
+ e.preventDefault();
1373
+ e.stopPropagation();
1374
+ }, true);
1375
+ addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
1376
+ addEventListener('resize', () => { if (tipFor) hideTip(); });
1377
+ $('models-more').addEventListener('click', () => {
1378
+ const before = $('models-list').childElementCount;
1379
+ modelsView.all = true;
1380
+ renderModels();
1381
+ $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
1382
+ });
898
1383
  $('stop-manager').addEventListener('click', () => stopManager());
899
1384
  $('panel-stop').addEventListener('click', () => {
900
1385
  const s = state.sessions.get(state.activeId);
package/web/art/art.css CHANGED
@@ -109,6 +109,50 @@
109
109
  .meter.low .meter-fill { background: var(--warn); box-shadow: 0 0 8px rgba(224, 168, 74, 0.5); }
110
110
  .meter.empty .meter-fill { background: var(--danger); box-shadow: 0 0 8px rgba(239, 107, 102, 0.5); }
111
111
 
112
+ .model-stats-head { color: var(--gold); }
113
+ .info { color: #b89a5e; }
114
+ .info:hover, .info:focus-visible { color: var(--gold); }
115
+ .tip {
116
+ background: #12141a; color: #e9e2cf; border: 1px solid #4a3d25;
117
+ box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 10px 28px rgba(0, 0, 0, 0.6);
118
+ }
119
+ .tip strong { color: var(--gold); font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif; }
120
+ .stat-bar { height: 7px; background: #0c0e12; box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.18); }
121
+ .stat-fill {
122
+ background: linear-gradient(90deg, color-mix(in srgb, var(--tier) 55%, #000), var(--tier));
123
+ box-shadow: 0 0 8px color-mix(in srgb, var(--tier) 50%, transparent);
124
+ }
125
+ .stat-tier {
126
+ border-radius: 3px; font-family: "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
127
+ background: linear-gradient(color-mix(in srgb, var(--tier) 34%, #1a1d26), #0d0f15);
128
+ border-color: color-mix(in srgb, var(--tier) 70%, #000);
129
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 0 6px color-mix(in srgb, var(--tier) 30%, transparent);
130
+ }
131
+ .tier-s .stat-tier {
132
+ color: #2b1b06; border-color: #f6dea6;
133
+ background: linear-gradient(180deg, #fbe9bd 0%, #e2b867 55%, #a97b35 100%);
134
+ box-shadow: 0 0 10px rgba(232, 196, 124, 0.6);
135
+ }
136
+
137
+ .models {
138
+ border-radius: 6px; border-color: #4a3d25; background: #12141a;
139
+ box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 24px 60px rgba(0, 0, 0, 0.6);
140
+ }
141
+ .models::after {
142
+ content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
143
+ border: 44px solid transparent; border-image: url("/art/ui/frame.png") 168;
144
+ }
145
+ .models::backdrop { background: rgba(5, 6, 9, 0.72); }
146
+ .models-bar { padding: 20px 24px 12px; }
147
+ .models-body { padding: 0 24px 14px; }
148
+ .models-source { padding: 10px 24px 20px; }
149
+ .models-bar, .models-source, .model-columns, .model { border-color: #2a2418; }
150
+ .model-columns { background: #12141a; }
151
+ .model-toggle:hover { background: rgba(232, 196, 124, 0.06); }
152
+ .model-group h4 { color: #c9ab72; }
153
+ .badge { background: rgba(0, 0, 0, 0.45); border-color: #4a3d25; }
154
+ .model-pill:hover { border-color: var(--gold-dim); }
155
+
112
156
  .session-card { padding-left: 152px; min-height: 196px; }
113
157
  .session-card::before {
114
158
  inset: 0 auto 0 0; width: 168px; background-size: 168px auto;
package/web/index.html CHANGED
@@ -83,6 +83,26 @@
83
83
  <div id="terminal-host" class="terminal-host"></div>
84
84
  </div>
85
85
 
86
+ <dialog id="models" class="models" aria-labelledby="models-title">
87
+ <div class="models-bar">
88
+ <span id="models-icon" class="provider-icon small"></span>
89
+ <div class="models-heading">
90
+ <h2 id="models-title"></h2>
91
+ <p id="models-sub" class="sub"></p>
92
+ </div>
93
+ <button id="models-close" class="btn" type="button">Close</button>
94
+ </div>
95
+ <div class="models-body">
96
+ <div id="models-columns" class="model-columns" aria-hidden="true"></div>
97
+ <div id="models-list"></div>
98
+ <button id="models-more" class="btn models-more" type="button" hidden></button>
99
+ <p id="models-note" class="models-note"></p>
100
+ </div>
101
+ <p id="models-source" class="models-source"></p>
102
+ </dialog>
103
+
104
+ <div id="tip" class="tip" aria-hidden="true" hidden><strong id="tip-title"></strong><span id="tip-source"></span><p id="tip-text"></p></div>
105
+
86
106
  <div id="toast" class="toast" role="alert" hidden></div>
87
107
 
88
108
  <template id="provider-template">
@@ -94,6 +114,7 @@
94
114
  <span class="state"></span>
95
115
  </span>
96
116
  <div class="usage"></div>
117
+ <div class="model-stats"></div>
97
118
  <div class="provider-links">
98
119
  <a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage ↗</a>
99
120
  <a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing ↗</a>
@@ -120,6 +141,28 @@
120
141
  </div>
121
142
  </template>
122
143
 
144
+ <template id="stat-template">
145
+ <div class="stat">
146
+ <span class="stat-label"><span class="stat-name"></span><button class="info" type="button">i</button></span>
147
+ <span class="stat-reading" role="img">
148
+ <span class="stat-bar"><span class="stat-fill"></span></span>
149
+ <span class="stat-value"></span>
150
+ <span class="stat-note"></span>
151
+ </span>
152
+ </div>
153
+ </template>
154
+
155
+ <template id="model-template">
156
+ <section class="model">
157
+ <h3 class="model-head">
158
+ <button class="model-toggle" type="button" aria-expanded="false">
159
+ <span class="model-title"><span class="model-name"></span></span>
160
+ </button>
161
+ </h3>
162
+ <div class="model-detail" hidden></div>
163
+ </section>
164
+ </template>
165
+
123
166
  <template id="session-template">
124
167
  <article class="session-card" role="listitem" tabindex="0">
125
168
  <span class="level-badge" aria-hidden="true"></span>
@@ -129,7 +172,7 @@
129
172
  <div class="name"></div>
130
173
  <div class="meta"></div>
131
174
  </div>
132
- <span class="model-pill"></span>
175
+ <button class="model-pill" type="button"></button>
133
176
  <span class="status-pill"></span>
134
177
  </div>
135
178
  <div class="cwd-line"></div>
package/web/styles.css CHANGED
@@ -69,7 +69,7 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
69
69
 
70
70
  .providers { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
71
71
  .provider {
72
- display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 10px 12px; padding: 14px;
72
+ display: grid; grid-template-columns: auto 1fr; align-items: center; align-content: start; gap: 10px 12px; padding: 14px;
73
73
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow);
74
74
  }
75
75
  .provider.unavailable .provider-icon { filter: grayscale(1); opacity: 0.45; }
@@ -96,6 +96,69 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
96
96
  .meter.empty .meter-fill { background: var(--danger); }
97
97
  .meter-value { white-space: nowrap; font-variant-numeric: tabular-nums; }
98
98
  .usage-note { font-size: 0.75rem; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
99
+ .provider .model-stats {
100
+ grid-column: 1 / -1; display: grid; grid-template-columns: max-content minmax(24px, 1fr) auto; align-items: center; gap: 2px 8px;
101
+ }
102
+ .provider .model-stats:empty { display: none; }
103
+ .provider .model-stats > .usage-note { grid-column: 1 / -1; contain: inline-size; }
104
+ .model-stats-head {
105
+ grid-column: 1 / -1; justify-self: start; max-width: 100%; min-height: 24px; padding: 0; border: 0; background: none; color: var(--accent);
106
+ font: inherit; font-size: 0.78rem; text-align: left; cursor: pointer;
107
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
108
+ }
109
+ .model-stats-head::after { content: " ›"; }
110
+ .model-stats-head:hover { text-decoration: underline; }
111
+ .model-stats-head:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
112
+
113
+ .stat {
114
+ --tier: var(--border);
115
+ grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center;
116
+ min-height: 24px; font-size: 0.75rem; color: var(--muted);
117
+ }
118
+ .stat-label { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
119
+ .stat-reading { grid-column: 2 / -1; display: grid; grid-template-columns: subgrid; align-items: center; }
120
+ .info {
121
+ position: relative; flex: none; display: inline-grid; place-items: center; width: 1.3em; height: 1.3em; padding: 0;
122
+ border: 1px solid color-mix(in srgb, currentColor 50%, transparent); border-radius: 50%; background: none; color: var(--muted);
123
+ font: italic 700 0.68rem/1 Georgia, "Times New Roman", serif; cursor: help;
124
+ }
125
+ .info::after { content: ""; position: absolute; inset: -6px; }
126
+ .info:hover, .info:focus-visible { color: var(--accent); border-color: currentColor; }
127
+ .info:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
128
+ .tip {
129
+ position: fixed; z-index: 30; width: max-content; max-width: min(320px, calc(100vw - 16px)); margin: 0; padding: 9px 11px;
130
+ border-radius: 8px; background: var(--text); color: var(--bg); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
131
+ font-size: 0.78rem; line-height: 1.45; pointer-events: none;
132
+ }
133
+ .tip strong { font-weight: 600; }
134
+ .tip span { margin-left: 6px; font-size: 0.72rem; opacity: 0.75; }
135
+ .tip span::before { content: "· "; }
136
+ .tip p { margin: 4px 0 0; }
137
+ .stat-bar { height: 6px; border-radius: 3px; background: var(--surface-2); overflow: hidden; }
138
+ .stat-fill { display: block; width: 0; height: 100%; border-radius: 3px; background: var(--tier); }
139
+ .stat-value, .model-index { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; font-variant-numeric: tabular-nums; color: var(--text); }
140
+ .stat-value { justify-content: flex-end; }
141
+ .stat-value .level, .model-index .level { min-width: 3ch; text-align: right; }
142
+ .stat.unmeasured .stat-value, .model-index.unmeasured { color: var(--muted); }
143
+ .stat-note { display: none; }
144
+ .stat-tier {
145
+ display: inline-grid; place-items: center; width: 1.7em; height: 1.7em; border-radius: 4px;
146
+ font-size: 0.68rem; font-weight: 700; line-height: 1;
147
+ color: var(--tier-ink); background: color-mix(in srgb, var(--tier) 16%, var(--surface));
148
+ border: 1px solid color-mix(in srgb, var(--tier) 50%, transparent);
149
+ }
150
+ .tier-s { --tier: #c99a2e; --tier-ink: #7a5200; }
151
+ .tier-a { --tier: #8b5cf6; --tier-ink: #5b21b6; }
152
+ .tier-b { --tier: #3b82f6; --tier-ink: #1d4ed8; }
153
+ .tier-c { --tier: #22a35a; --tier-ink: #166534; }
154
+ .tier-d { --tier: #94a3b8; --tier-ink: #475569; }
155
+ @media (prefers-color-scheme: dark) {
156
+ .tier-s { --tier: #e0b45c; --tier-ink: #f6dea6; }
157
+ .tier-a { --tier: #a78bfa; --tier-ink: #ddd3ff; }
158
+ .tier-b { --tier: #60a5fa; --tier-ink: #cfe3ff; }
159
+ .tier-c { --tier: #4ade80; --tier-ink: #c6f6d5; }
160
+ .tier-d { --tier: #94a3b8; --tier-ink: #d4dbe4; }
161
+ }
99
162
  .provider-links { grid-column: 1 / -1; display: flex; gap: 14px; font-size: 0.78rem; }
100
163
  .console-link { color: var(--accent); text-decoration: none; }
101
164
  .console-link:hover { text-decoration: underline; }
@@ -139,7 +202,10 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
139
202
  font-size: 0.72rem; font-weight: 600; padding: 3px 8px; border-radius: 999px; white-space: nowrap;
140
203
  max-width: 11em; overflow: hidden; text-overflow: ellipsis;
141
204
  background: color-mix(in srgb, var(--accent) 14%, var(--surface)); color: var(--accent);
205
+ border: 0; font-family: inherit; cursor: pointer;
142
206
  }
207
+ .model-pill:hover { background: color-mix(in srgb, var(--accent) 24%, var(--surface)); }
208
+ .model-pill:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
143
209
  .model-pill.screen { font-weight: 500; }
144
210
  .status-pill.active { color: var(--ok); }
145
211
  .status-pill.active::before { content: "● "; animation: pulse 1.2s ease-in-out infinite; }
@@ -203,6 +269,67 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
203
269
  .terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
204
270
  .terminal-host > div { height: 100%; }
205
271
 
272
+ .models {
273
+ width: min(880px, calc(100vw - 32px)); max-height: calc(100vh - 48px); padding: 0; overflow: hidden;
274
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
275
+ box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35);
276
+ }
277
+ .models[open] { display: flex; flex-direction: column; }
278
+ .models::backdrop { background: rgba(15, 17, 21, 0.5); }
279
+ .models-bar { display: flex; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
280
+ .models-heading { flex: 1; min-width: 0; }
281
+ .models-heading .sub { margin: 2px 0 0; color: var(--muted); font-size: 0.78rem; }
282
+ .models-body { flex: 1; min-height: 0; overflow: auto; padding: 0 14px 14px; }
283
+ .model-columns, .model-toggle { display: grid; grid-template-columns: minmax(0, 1fr) repeat(3, 6.5rem) 4.5rem; align-items: center; gap: 8px; }
284
+ .model-columns {
285
+ position: sticky; top: 0; z-index: 1; padding: 10px 8px 6px; background: var(--surface);
286
+ border-bottom: 1px solid var(--border); color: var(--muted); font-size: 0.72rem; font-weight: 600;
287
+ }
288
+ .model-columns span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
289
+ .model-columns span:last-child, .model-context { text-align: right; }
290
+ .model { border-bottom: 1px solid var(--border); }
291
+ .model-head { margin: 0; font: inherit; }
292
+ .model-toggle {
293
+ width: 100%; padding: 9px 8px; border: 0; border-radius: 6px; background: none; color: var(--text);
294
+ font: inherit; font-size: 0.85rem; text-align: left; cursor: pointer;
295
+ }
296
+ .model-toggle:hover { background: var(--surface-2); }
297
+ .model-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
298
+ .model-title { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; min-width: 0; }
299
+ .model-name { font-weight: 600; overflow-wrap: anywhere; }
300
+ .model-name::before { content: "▸"; content: "▸" / ""; display: inline-block; width: 1em; color: var(--muted); transition: transform 0.15s; }
301
+ .model-toggle[aria-expanded="true"] .model-name::before { transform: rotate(90deg); }
302
+ .model-context { color: var(--muted); font-variant-numeric: tabular-nums; }
303
+ .badge {
304
+ font-size: 0.66rem; font-weight: 600; padding: 1px 7px; border-radius: 999px; white-space: nowrap;
305
+ background: var(--surface-2); color: var(--muted); border: 1px solid var(--border);
306
+ }
307
+ .badge.in-use { --badge: var(--accent); color: color-mix(in srgb, var(--badge) 80%, #000); }
308
+ .badge.new { --badge: var(--ok); color: color-mix(in srgb, var(--badge) 72%, #000); }
309
+ .badge.expires { --badge: var(--warn); color: color-mix(in srgb, var(--badge) 62%, #000); }
310
+ .badge.in-use, .badge.new, .badge.expires { border-color: color-mix(in srgb, var(--badge) 45%, transparent); }
311
+ @media (prefers-color-scheme: dark) {
312
+ .badge.in-use, .badge.new, .badge.expires { color: var(--badge); }
313
+ }
314
+ .model-detail {
315
+ display: grid; grid-template-columns: max-content minmax(48px, 1fr) auto minmax(0, 19em); gap: 14px 10px;
316
+ padding: 4px 8px 16px calc(8px + 1em);
317
+ }
318
+ .model-group { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; row-gap: 2px; }
319
+ .model-group h4, .model-none { grid-column: 1 / -1; }
320
+ .model-group h4 { margin: 0 0 2px; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
321
+ .model-detail .stat-note { display: block; font-size: 0.72rem; }
322
+ .model-detail .stat-fill { transform-origin: left; animation: stat-fill 0.45s ease-out; }
323
+ .model-none { margin: 0; font-size: 0.75rem; color: var(--muted); }
324
+ .model-more { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 6px 16px; font-size: 0.78rem; }
325
+ .model-facts { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 0; }
326
+ .model-facts div { display: flex; gap: 6px; }
327
+ .model-facts dt { color: var(--muted); }
328
+ .model-facts dd { margin: 0; }
329
+ .models-more { margin-top: 12px; }
330
+ .models-note { margin: 14px 0 0; color: var(--muted); }
331
+ .models-source { margin: 0; padding: 10px 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.75rem; line-height: 1.45; }
332
+
206
333
  .toast {
207
334
  position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%); z-index: 20;
208
335
  max-width: min(640px, calc(100vw - 32px)); padding: 10px 14px; border-radius: 8px;
@@ -211,9 +338,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
211
338
 
212
339
  @keyframes pulse { 50% { opacity: 0.35; } }
213
340
  @keyframes ring { 0% { transform: scale(0.85); opacity: 0.8; } 100% { transform: scale(1.3); opacity: 0; } }
341
+ @keyframes stat-fill { from { transform: scaleX(0); } }
214
342
 
215
343
  @media (prefers-reduced-motion: reduce) {
216
- .status-pill.active::before, .agent.working::after { animation: none; }
344
+ .status-pill.active::before, .agent.working::after, .model-detail .stat-fill { animation: none; }
345
+ .model-name::before { transition: none; }
217
346
  }
218
347
 
219
348
  @media (max-width: 640px) {
@@ -223,4 +352,11 @@ main { max-width: 1200px; margin: 0 auto; padding: 20px 16px 48px; display: grid
223
352
  .terminal-panel { inset: var(--topbar-h, 56px) 0 0; border-radius: 0; }
224
353
  .session-grid { grid-template-columns: 1fr; }
225
354
  .cwd { flex-basis: 100%; }
355
+ .models { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; }
356
+ .model-columns, .model-toggle { grid-template-columns: minmax(0, 1fr) repeat(3, 4.6rem); }
357
+ .model-columns span:last-child, .model-context { display: none; }
358
+ .model-detail { padding-left: 8px; grid-template-columns: max-content minmax(32px, 1fr) auto; }
359
+ .model-detail .stat { padding-bottom: 6px; }
360
+ .model-detail .stat-reading { row-gap: 1px; }
361
+ .model-detail .stat-note { grid-column: 1 / -1; }
226
362
  }