llm-switcher 1.2.7 → 1.2.9

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.
@@ -282,6 +282,22 @@ test('the POSIX shims source launch files only when this account owns them', ()
282
282
 
283
283
  // ISS-CC-LS-002: on zsh a later line in .zshrc or .zprofile can prepend npm-global or Homebrew
284
284
  // ahead of the shim. The hint names both files and says the line must come last.
285
+ test('pathOrderHint gives a fish user the fish file and fish syntax', () => {
286
+ // `export PATH=...` is not fish syntax. A fish user who pasted the old hint got an error, and
287
+ // the hint named ~/.profile, which fish does not read.
288
+ const shell = process.env.SHELL;
289
+ process.env.SHELL = '/usr/bin/fish';
290
+ try {
291
+ assert.deepEqual(suggestedRcFiles('linux'), [path.join(os.homedir(), '.config', 'fish', 'config.fish')]);
292
+ const text = pathOrderHint('linux').join(' ');
293
+ assert.ok(text.includes('config.fish'), text);
294
+ assert.ok(text.includes('fish_add_path'), text);
295
+ assert.ok(!text.includes('export PATH='), text);
296
+ } finally {
297
+ if (shell === undefined) delete process.env.SHELL; else process.env.SHELL = shell;
298
+ }
299
+ });
300
+
285
301
  test('pathOrderHint tells a zsh user to put the export last in .zshrc and .zprofile', () => {
286
302
  const shell = process.env.SHELL;
287
303
  process.env.SHELL = '/bin/zsh';
@@ -340,3 +356,99 @@ test('A8: the Windows shim scrubs a loopback base URL and resolves every subrout
340
356
  assert.match(child.stdout || '', /HTTPS=\[http:\/\/127\.0\.0\.1:3457\]/,
341
357
  'R2: the proxy comes from the env file of this tool');
342
358
  });
359
+
360
+ // ---- The route notice: the launcher tells the person the switcher took the traffic -------------
361
+
362
+ // A person opens `claude`, the switcher routes it to another provider, and nothing on screen says
363
+ // so. The shim therefore raises a notice at launch. It is guarded by TOOL_ACTIVE, so a tool whose
364
+ // profile is off stays silent: the guard is the same one that decides whether to source the env.
365
+ test('the route notice is guarded by TOOL_ACTIVE and names only this tool route file', () => {
366
+ for (const platform of ['win32', 'linux']) {
367
+ const claude = renderShim('claude', platform);
368
+ const codex = renderShim('codex', platform);
369
+ assert.match(claude, /route-claude\.txt/, `${platform}: the claude shim reads its own route file`);
370
+ assert.doesNotMatch(claude, /route-codex/, `${platform}: and never the other tool file`);
371
+ assert.match(codex, /route-codex\.txt/);
372
+ assert.doesNotMatch(codex, /route-claude/);
373
+ // Everything that raises the notice sits after the TOOL_ACTIVE decision, never before it.
374
+ const guard = platform === 'win32' ? claude.indexOf('set "TOOL_ACTIVE=1"') : claude.indexOf('TOOL_ACTIVE=1');
375
+ assert.ok(guard > 0, `${platform}: the guard exists`);
376
+ assert.ok(claude.indexOf('route-claude.txt') > guard, `${platform}: the notice comes after the guard`);
377
+ }
378
+ });
379
+
380
+ test('the POSIX shim prints the route line for an active tool and nothing for one that is off', (t) => {
381
+ if (process.platform === 'win32') return t.skip('posix only');
382
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfake-'));
383
+ t.after(() => fs.rmSync(fakeDir, { recursive: true, force: true }));
384
+ fs.writeFileSync(path.join(fakeDir, 'claude'), '#!/bin/sh\necho ran\n');
385
+ fs.chmodSync(path.join(fakeDir, 'claude'), 0o755);
386
+
387
+ const run = ({ active, route }) => {
388
+ for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
389
+ if (active) {
390
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
391
+ fs.writeFileSync(path.join(STATE, 'env-claude.sh'), "export HTTPS_PROXY='http://127.0.0.1:3457'\n");
392
+ }
393
+ fs.writeFileSync(path.join(STATE, 'route-claude.txt'), route);
394
+ return spawnSync(path.join(RENDER_DIR, 'claude'), [], {
395
+ encoding: 'utf8', timeout: 15000,
396
+ env: { ...process.env, PATH: [RENDER_DIR, fakeDir, '/usr/bin:/bin', NODE_DIR].join(path.delimiter) }
397
+ });
398
+ };
399
+
400
+ const on = run({ active: true, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
401
+ assert.match(on.stderr, /intact\.example\.io/, 'an active tool shows where its traffic goes');
402
+ assert.match(on.stdout, /ran/, 'and the real binary still runs');
403
+
404
+ const off = run({ active: false, route: 'claude -> cl | intact.example.io | gemini | 1M\n' });
405
+ assert.doesNotMatch(off.stderr, /intact\.example\.io/, 'a tool that is off says nothing, even with a stale route file');
406
+ assert.match(off.stdout, /ran/);
407
+ });
408
+
409
+ // The Windows notice is a detached toast, so the only way to prove it fires is to put a fake
410
+ // `powershell` on PATH and read what the shim asked it to do.
411
+ test('the Windows shim raises the toast only while this tool is routed', (t) => {
412
+ if (process.platform !== 'win32') return t.skip('cmd.exe only');
413
+ const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimwintoast-'));
414
+ const fakeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'shimfaketoast-'));
415
+ t.after(() => {
416
+ fs.rmSync(shimDir, { recursive: true, force: true });
417
+ fs.rmSync(fakeDir, { recursive: true, force: true });
418
+ });
419
+ const log = path.join(fakeDir, 'toast.log');
420
+ fs.writeFileSync(path.join(fakeDir, 'claude.cmd'), '@echo off\r\necho ran\r\n');
421
+ fs.writeFileSync(path.join(fakeDir, 'powershell.cmd'), `@echo off\r\necho %*>>"${log}"\r\n`);
422
+ const shim = path.join(shimDir, 'shim.cmd');
423
+ fs.writeFileSync(shim, renderShim('claude', 'win32'), 'utf8');
424
+
425
+ const run = ({ active }) => {
426
+ for (const f of fs.readdirSync(STATE)) fs.rmSync(path.join(STATE, f), { force: true });
427
+ fs.rmSync(log, { force: true });
428
+ if (active) {
429
+ fs.writeFileSync(path.join(STATE, 'active.flag'), 'active');
430
+ fs.writeFileSync(path.join(STATE, 'env-claude.cmd'), 'SET "HTTPS_PROXY=http://127.0.0.1:3457"\r\n');
431
+ }
432
+ fs.writeFileSync(path.join(STATE, 'route-claude.txt'), 'claude -> cl | intact.example.io | gemini | 1M\r\n');
433
+ const child = spawnSync('cmd.exe', ['/d', '/c', shim, '--version'], {
434
+ encoding: 'utf8', timeout: 30000,
435
+ env: {
436
+ ...process.env,
437
+ PATH: [fakeDir, shimDir, path.join(process.env.SystemRoot, 'System32'), path.dirname(process.execPath)]
438
+ .join(path.delimiter)
439
+ }
440
+ });
441
+ // `start /b` detaches, so the fake may still be writing when the shim has already exited.
442
+ for (let i = 0; i < 60 && !fs.existsSync(log); i++) execFileSync(process.execPath, ['-e', 'setTimeout(()=>{},50)']);
443
+ return { out: child.stdout || '', asked: fs.existsSync(log) ? fs.readFileSync(log, 'utf8') : '' };
444
+ };
445
+
446
+ const on = run({ active: true });
447
+ assert.match(on.out, /ran/, 'the real binary still runs');
448
+ assert.match(on.asked, /route-claude\.txt/, 'the toast is asked for, with this tool route file');
449
+ assert.match(on.asked, /notify-route\.ps1/, 'through the notifier that ships with the switcher');
450
+
451
+ const off = run({ active: false });
452
+ assert.match(off.out, /ran/);
453
+ assert.equal(off.asked, '', 'a tool that is off raises no toast');
454
+ });
@@ -7,7 +7,7 @@ import {
7
7
  modelSlotsForProfile, modelForSlot, primaryModel, codexPublicModel,
8
8
  certCoversHost, blindfoldPreflight, ROOT_DIR, openLog, probeGateway, isSafeModelName,
9
9
  getMigrationCollision, getMigrationError, getConfigLoadError, saveConfig, loadConfig,
10
- migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration
10
+ migrateConfigInMemory, saveConfigAtomicCAS, TOOLS, validateProfileInput, needsMigration, hasPlaceholder
11
11
  } from '../state.mjs';
12
12
  import fs from 'node:fs';
13
13
  import path from 'node:path';
@@ -52,6 +52,76 @@ test('activateProfile only assigns compatible tools; deactivateProfile leaves th
52
52
  assert.deepEqual(cfg.activeProfiles, { claude: null, codex: null });
53
53
  });
54
54
 
55
+ test('switching a migrated config does not make it need migration again', () => {
56
+ // A legacy pointer written on every switch re-ran the migration on the next load and left one
57
+ // more config.json.bak-* (with the API keys) in the data folder each time.
58
+ const cfg = {
59
+ port: 4000,
60
+ activeProfiles: { claude: null, codex: null },
61
+ profiles: {
62
+ router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
63
+ codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
64
+ }
65
+ };
66
+ assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
67
+ assert.equal(activateProfile(cfg, 'router'), null);
68
+ assert.equal(setTargetProfile(cfg, 'codex', 'codexOnly'), null);
69
+ assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
70
+ assert.equal(needsMigration(cfg), false);
71
+ });
72
+
73
+ test('hasPlaceholder spots the example config values', () => {
74
+ assert.equal(hasPlaceholder({ baseURL: 'https://YOUR-ROUTER-HOST/v1', apiKey: 'sk-real' }), true);
75
+ assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'REPLACE-ME' }), true);
76
+ assert.equal(hasPlaceholder({ apiKey: 'sk-real' }), true, 'no baseURL');
77
+ assert.equal(hasPlaceholder({ baseURL: 'https://r/v1', apiKey: 'sk-real' }), false);
78
+ assert.equal(hasPlaceholder({ baseURL: 'http://127.0.0.1:11434/v1' }), false, 'a keyless local backend is fine');
79
+ });
80
+
81
+ test('the interceptor port follows LLM_SWITCHER_BLINDFOLD_PORT and never lands on the gateway port', () => {
82
+ // The gateway reads --port and LLM_SWITCHER_PORT through resolvePort. The interceptor read only
83
+ // config.json, so a second instance collided and HTTPS_PROXY named a port nothing listened on.
84
+ const saved = process.env.LLM_SWITCHER_BLINDFOLD_PORT;
85
+ delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
86
+ const cfg = {
87
+ port: 4000,
88
+ activeProfiles: { claude: 'router', codex: null },
89
+ profiles: { router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } } }
90
+ };
91
+ try {
92
+ assert.equal(computeLaunchState(cfg, 4000).blindfold.port, 3457, 'the default');
93
+ assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 4444, 'the file');
94
+ process.env.LLM_SWITCHER_BLINDFOLD_PORT = '5555';
95
+ assert.equal(computeLaunchState({ ...cfg, blindfold: { port: 4444 } }, 4000).blindfold.port, 5555, 'the environment wins');
96
+ delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
97
+ // A gateway moved onto 3457 by --port or by the environment must push the interceptor off it.
98
+ const st = computeLaunchState(cfg, 3457);
99
+ assert.notEqual(st.blindfold.port, 3457, 'the interceptor moved off the gateway port');
100
+ const proxy = st.envClaude.find(([k]) => k === 'HTTPS_PROXY');
101
+ assert.equal(proxy[1], `http://127.0.0.1:${st.blindfold.port}`, 'HTTPS_PROXY names the real port');
102
+ } finally {
103
+ if (saved === undefined) delete process.env.LLM_SWITCHER_BLINDFOLD_PORT;
104
+ else process.env.LLM_SWITCHER_BLINDFOLD_PORT = saved;
105
+ }
106
+ });
107
+
108
+ test('deleteProfile does not make a migrated config need migration again', () => {
109
+ // Reachable from the dashboard Delete Profile button via POST /api/delete-profile, which
110
+ // persists. A legacy pointer written here revives the backup leak the switch path just lost.
111
+ const cfg = {
112
+ port: 4000,
113
+ activeProfiles: { claude: null, codex: 'codexOnly' },
114
+ profiles: {
115
+ router: { tool: 'claude', apiKey: 'sk-1', baseURL: 'https://r/v1', defaultModels: { opus: 'o' } },
116
+ codexOnly: { tool: 'codex', apiKey: 'sk-2', baseURL: 'https://c/v1', defaultModels: { main: 'gpt-main' } }
117
+ }
118
+ };
119
+ assert.equal(needsMigration(cfg), false, 'fixture is already migrated');
120
+ assert.equal(deleteProfile(cfg, 'codexOnly'), null);
121
+ assert.equal(Object.hasOwn(cfg, 'activeProfile'), false);
122
+ assert.equal(needsMigration(cfg), false);
123
+ });
124
+
55
125
  test('deleteProfile unassigns targets that pointed at it', () => {
56
126
  const cfg = makeCfg();
57
127
  activateProfile(cfg, 'codexOnly');
@@ -602,15 +672,21 @@ test('isSafeModelName refuses names that TOML reads as something other than a st
602
672
  test('launch-file writers take turns through a lock, and a dead holder does not block them', async (t) => {
603
673
  const { dir, env } = tmpDirs(t);
604
674
  const lock = path.join(dir, '.launch.lock');
675
+ const startedFile = path.join(dir, 'writer.started');
605
676
  // A live holder: this test process. The child must wait until the lock is gone.
606
677
  fs.writeFileSync(lock, String(process.pid));
607
- setTimeout(() => fs.rmSync(lock, { force: true }), 400);
608
- const started = Date.now();
609
- const r = await new Promise((resolve, reject) => {
678
+ const done = new Promise((resolve, reject) => {
610
679
  execFileCb(process.execPath, ['--input-type=module', '-e',
611
- `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
680
+ `const s = await import(${JSON.stringify(pathToFileURL(path.join(ROOT_DIR, 'state.mjs')).href)}); (await import('node:fs')).writeFileSync(${JSON.stringify(startedFile)}, ''); const t0 = Date.now(); s.applyLaunchState(s.loadConfig(), 4000); console.log(Date.now() - t0);`],
612
681
  { env: { ...process.env, LLM_SWITCHER_PORT: '', ...env }, encoding: 'utf8' }, (err, out) => (err ? reject(err) : resolve(Number(out.trim()))));
613
682
  });
683
+ // The 400 ms count from the writer's start, the same way the next test does it. A spawn plus an
684
+ // import measured 163 to 541 ms on Node 18, so a fixed timer left under 100 ms of wait and the
685
+ // assertion below failed on a loaded machine.
686
+ for (let i = 0; i < 1000 && !fs.existsSync(startedFile); i++) await new Promise(r => setTimeout(r, 10));
687
+ const started = Date.now();
688
+ setTimeout(() => fs.rmSync(lock, { force: true }), 400);
689
+ const r = await done;
614
690
  // Without the lock the write takes a few ms; with it the child waits for the release at 400 ms.
615
691
  assert.ok(r >= 100, `the writer waited ${r} ms for the live holder`);
616
692
  assert.ok(Date.now() - started >= 380);
@@ -1496,3 +1572,52 @@ test('A5: config.example.json loads and passes validation without requiring migr
1496
1572
  }
1497
1573
  }
1498
1574
  });
1575
+
1576
+ // ---- Route lines: what the launcher shows when it routes a tool -------------------------------
1577
+
1578
+ // A person opens `claude` and cannot see that the switcher took over the traffic. The launcher
1579
+ // therefore reads one line per tool, written beside that tool's env file. The line exists exactly
1580
+ // when that tool is routed, so a tool whose profile is off stays silent with no extra condition.
1581
+ test('each routed tool gets a route line; a tool that is off gets an empty one', (t) => {
1582
+ const { dir, env } = tmpDirs(t);
1583
+ fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({
1584
+ port: 4000,
1585
+ activeProfiles: { claude: 'cl', codex: 'cx' },
1586
+ profiles: {
1587
+ cl: {
1588
+ name: 'Intact Claude', baseURL: 'https://intact.example.io/v1', apiKey: 'sk-secret-claude',
1589
+ defaultModels: { opus: 'gemini-3.8-flash' }, model1M: { opus: true }
1590
+ },
1591
+ cx: {
1592
+ inFormat: 'responses', baseURL: 'https://other.example.io/v1', apiKey: 'sk-secret-codex',
1593
+ defaultModels: { main: 'gpt-5.6-sol' }
1594
+ }
1595
+ }
1596
+ }), { mode: 0o600 });
1597
+
1598
+ const r = runState(env, `
1599
+ const read = (f) => { try { return fs.readFileSync(f, 'utf8'); } catch { return null; } };
1600
+ s.applyLaunchState(s.loadConfig(), 4000);
1601
+ const on = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1602
+ s.emptyToolEnvFiles('claude');
1603
+ const oneOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1604
+ s.applyLaunchState(s.loadConfig(), 4000);
1605
+ s.clearLaunchState(4000);
1606
+ const allOff = { claude: read(s.paths.routeClaude), codex: read(s.paths.routeCodex) };
1607
+ return { on, oneOff, allOff };
1608
+ `);
1609
+
1610
+ assert.match(r.on.claude, /\bcl\b/, 'the line names the profile that serves claude');
1611
+ assert.match(r.on.claude, /intact\.example\.io/, 'and the host the traffic goes to');
1612
+ assert.match(r.on.claude, /gemini-3\.8-flash/, 'and the model it maps to');
1613
+ assert.match(r.on.claude, /1M/, 'and the 1M window, because that changes what the session costs');
1614
+ assert.match(r.on.codex, /\bcx\b/);
1615
+ assert.match(r.on.codex, /other\.example\.io/);
1616
+ assert.doesNotMatch(r.on.codex, /intact\.example\.io/, 'one tool never shows the other tool route');
1617
+ assert.doesNotMatch(`${r.on.claude}${r.on.codex}`, /sk-secret/, 'a route line never carries the API key');
1618
+
1619
+ assert.equal(r.oneOff.claude.trim(), '', 'the tool switched off has no route line');
1620
+ assert.notEqual(r.oneOff.codex.trim(), '', 'the other tool keeps its own');
1621
+ assert.equal(r.allOff.claude.trim(), '', 'switch off leaves no route line for either tool');
1622
+ assert.equal(r.allOff.codex.trim(), '');
1623
+ });
package/ui.html CHANGED
@@ -811,6 +811,10 @@
811
811
  position: sticky;
812
812
  top: 0;
813
813
  height: auto;
814
+ /* The base rule makes this a scroll container, and a grid item that scrolls has an automatic
815
+ minimum size of zero. The row then collapsed to the padding (16 px measured) and the nav
816
+ was drawn outside it, so no link could be pressed. Reset it, or the height is ignored. */
817
+ overflow: visible;
814
818
  flex-direction: row;
815
819
  flex-wrap: wrap;
816
820
  align-items: center;
@@ -926,7 +930,7 @@
926
930
  <div class="ph">
927
931
  <div class="title-box">
928
932
  <h1 id="page-title">Routes & Profiles</h1>
929
- <p id="status-sub">Route Claude Code, Codex, and OpenAI clients independently.</p>
933
+ <p id="status-sub">Route Claude Code and Codex independently.</p>
930
934
  </div>
931
935
  <div class="ph-actions" id="ph-actions">
932
936
  <!-- Rendered dynamically per route -->
@@ -1323,18 +1327,44 @@
1323
1327
  for (const ev of ['focusout', 'click', 'scroll']) addEventListener(ev, hideTip, true);
1324
1328
 
1325
1329
  // Modal Control
1330
+
1331
+ // A result belongs to the profile it was measured on. Cleared on every open, because a dialog
1332
+ // that opens showing "HTTP 200 OK" from the profile before it says the wrong thing about this one.
1333
+ // Empties the three fields, never the box: they are static markup, and innerHTML would delete
1334
+ // them for the rest of the session.
1335
+ function clearTestResult() {
1336
+ const box = document.getElementById('test-result');
1337
+ if (!box) return;
1338
+ for (const id of ['test-status-badge', 'test-latency', 'test-sample']) {
1339
+ const el = document.getElementById(id);
1340
+ if (el) el.textContent = '';
1341
+ }
1342
+ document.getElementById('test-status-badge')?.setAttribute('class', 'badge');
1343
+ box.className = 'test-result-box';
1344
+ }
1345
+
1326
1346
  function openProfileModal(isNew = false) {
1327
1347
  const modal = document.getElementById('profile-modal-overlay');
1328
1348
  if (modal) modal.style.display = 'grid';
1349
+ clearTestResult();
1329
1350
  if (isNew) {
1330
1351
  initNewProfile();
1331
1352
  }
1353
+ // Without this the focus stays on the button behind the overlay, so the first Tab walks into
1354
+ // the page under the dialog instead of through the dialog.
1355
+ const first = modal?.querySelector('input:not([readonly]):not([disabled]), select, textarea, button');
1356
+ first?.focus();
1332
1357
  }
1333
1358
 
1334
1359
  function closeProfileModal() {
1335
1360
  const modal = document.getElementById('profile-modal-overlay');
1336
1361
  if (modal) modal.style.display = 'none';
1337
1362
  formDirty = false;
1363
+ // render() reads this to draw the ring and the "Editing Profile" label. Left set, a card stayed
1364
+ // marked as being edited after Escape, Cancel or a press on the dark area.
1365
+ currentlyEditingKey = '';
1366
+ formLoadedKey = null;
1367
+ render();
1338
1368
  }
1339
1369
 
1340
1370
  // The close button, Cancel, the dark area and Escape. A save or a delete calls closeProfileModal
@@ -2086,6 +2116,15 @@
2086
2116
  if (existing.publicModels) profilePayload.publicModels = existing.publicModels;
2087
2117
 
2088
2118
  showToast('Saving profile "' + key + '"...');
2119
+ // A double-click sent the save twice. The same pattern as the catalog sync button: disabled
2120
+ // while the request runs, re-enabled in `finally` so a failed save can be tried again.
2121
+ const submit = e.target?.querySelector?.('button[type="submit"]')
2122
+ || document.querySelector('#profile-modal-overlay button[type="submit"]');
2123
+ if (submit) {
2124
+ if (submit.disabled) return;
2125
+ submit.disabled = true;
2126
+ submit.setAttribute('aria-busy', 'true');
2127
+ }
2089
2128
  try {
2090
2129
  const res = await api('/api/save-profile', {
2091
2130
  method: 'POST',
@@ -2102,6 +2141,11 @@
2102
2141
  }
2103
2142
  } catch (err) {
2104
2143
  showToast('Network error saving profile', true);
2144
+ } finally {
2145
+ if (submit) {
2146
+ submit.disabled = false;
2147
+ submit.removeAttribute('aria-busy');
2148
+ }
2105
2149
  }
2106
2150
  }
2107
2151
 
@@ -2434,8 +2478,15 @@
2434
2478
  const ctModels = document.getElementById('ct-models');
2435
2479
  if (ctModels) ctModels.textContent = claudeModels.length + codexModels.length;
2436
2480
 
2437
- document.getElementById('badge-claude-count').textContent = claudeModels.length + ' models · ' + claudeTiers.size + ' tiers';
2438
- document.getElementById('badge-codex-count').textContent = codexModels.length + ' models · ' + codexRoles.size + ' roles';
2481
+ // `data-full` is what the search box restores when it is emptied again.
2482
+ for (const [id, text] of [
2483
+ ['badge-claude-count', claudeModels.length + ' models · ' + claudeTiers.size + ' tiers'],
2484
+ ['badge-codex-count', codexModels.length + ' models · ' + codexRoles.size + ' roles']
2485
+ ]) {
2486
+ const el = document.getElementById(id);
2487
+ el.textContent = text;
2488
+ el.dataset.full = text;
2489
+ }
2439
2490
 
2440
2491
  claudeContainer.innerHTML = claudeModels.map(m => `
2441
2492
  <div class="row" style="padding:0.6rem 0.8rem; background:var(--card2); border-radius:8px;">
@@ -2465,12 +2516,40 @@
2465
2516
  }
2466
2517
  }
2467
2518
 
2519
+ // A filter that hides every row but keeps the header count reads as a broken page: the header
2520
+ // still promised 11 models over an empty list. So the count follows the filter, and an empty
2521
+ // result says so in words.
2468
2522
  function filterCatalogView(q) {
2469
2523
  const term = (q || '').toLowerCase();
2470
- document.querySelectorAll('#catalog-claude-items .row, #catalog-codex-items .row').forEach(row => {
2471
- const text = row.textContent.toLowerCase();
2472
- row.style.display = text.includes(term) ? 'flex' : 'none';
2473
- });
2524
+ for (const [list, badge, unit] of [
2525
+ ['#catalog-claude-items', 'badge-claude-count', 'models'],
2526
+ ['#catalog-codex-items', 'badge-codex-count', 'models']
2527
+ ]) {
2528
+ const container = document.querySelector(list);
2529
+ if (!container) continue;
2530
+ let shown = 0;
2531
+ container.querySelectorAll('.row').forEach(row => {
2532
+ const hit = row.textContent.toLowerCase().includes(term);
2533
+ row.style.display = hit ? 'flex' : 'none';
2534
+ if (hit) shown++;
2535
+ });
2536
+ let empty = container.querySelector('.catalog-empty');
2537
+ if (!shown) {
2538
+ if (!empty) {
2539
+ empty = document.createElement('div');
2540
+ empty.className = 'catalog-empty hint';
2541
+ empty.style.padding = '0.8rem';
2542
+ container.appendChild(empty);
2543
+ }
2544
+ empty.textContent = term ? 'No model matches "' + term + '".' : 'No model in this list.';
2545
+ empty.style.display = 'block';
2546
+ } else if (empty) {
2547
+ empty.style.display = 'none';
2548
+ }
2549
+ const el = document.getElementById(badge);
2550
+ // `data-full` is the unfiltered text that the render wrote. An empty box restores it.
2551
+ if (el) el.textContent = term ? shown + ' ' + unit + ' shown' : (el.dataset.full || el.textContent);
2552
+ }
2474
2553
  }
2475
2554
 
2476
2555
  async function refreshCatalogUpstream() {