free-coding-models 0.3.55 → 0.3.57

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +55 -56
  2. package/README.md +214 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +134 -310
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +66 -27
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +15 -13
  11. package/src/config.js +201 -35
  12. package/src/constants.js +4 -4
  13. package/src/endpoint-installer.js +45 -1
  14. package/src/favorites.js +22 -0
  15. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  16. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  17. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  18. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  19. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  20. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  21. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  22. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  23. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  24. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  25. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  26. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  27. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  28. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  29. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  30. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  31. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  32. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  33. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  34. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  35. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  36. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  37. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  38. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  39. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  40. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  41. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  42. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  43. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  44. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  45. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  46. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  47. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  48. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  49. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  50. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  51. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  52. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  53. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  54. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  55. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  56. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  57. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  58. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  59. package/src/key-handler.js +322 -114
  60. package/src/kilo.js +20 -1
  61. package/src/opencode.js +23 -2
  62. package/src/overlays.js +199 -98
  63. package/src/provider-metadata.js +26 -17
  64. package/src/quota-capabilities.js +6 -10
  65. package/src/render-helpers.js +38 -8
  66. package/src/render-table.js +119 -248
  67. package/src/router-daemon.js +1986 -0
  68. package/src/router-dashboard.js +902 -0
  69. package/src/sync-set.js +479 -0
  70. package/src/theme.js +4 -0
  71. package/src/tool-launchers.js +1 -0
  72. package/src/tool-metadata.js +6 -2
  73. package/src/utils.js +30 -6
  74. package/web/dist/assets/{index-C03JjCgA.js → index-DKHCzbK1.js} +2 -2
  75. package/web/dist/index.html +1 -1
@@ -8,7 +8,7 @@
8
8
  * tool launch actions. It also keeps the live key bindings aligned with the
9
9
  * highlighted letters shown in the table headers.
10
10
  *
11
- * 📖 Key I opens the unified "Feedback, bugs & requests" overlay.
11
+ * 📖 Key I opens the changelog overlay.
12
12
  *
13
13
  * It also owns the "test key" model selection used by the Settings overlay.
14
14
  * Anonymous telemetry hooks for model launches and a few high-signal settings
@@ -33,7 +33,10 @@
33
33
 
34
34
  import { loadChangelog } from './changelog-loader.js'
35
35
  import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from './tool-metadata.js'
36
- import { loadConfig, replaceConfigContents } from './config.js'
36
+ import { loadConfig, saveConfig, replaceConfigContents } from './config.js'
37
+ import { join, dirname } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+ import { spawn } from 'node:child_process'
37
40
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
38
41
  import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
39
42
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
@@ -42,12 +45,23 @@ import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntrie
42
45
  import { WIDTH_WARNING_MIN_COLS, VERDICT_CYCLE, HEALTH_CYCLE } from './constants.js'
43
46
  import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
44
47
  import { startExternalTool } from './tool-launchers.js'
48
+ import {
49
+ clearRouterDashboardRequestLog,
50
+ closeRouterDashboardOverlay,
51
+ cycleRouterDashboardActiveSet,
52
+ cycleRouterDashboardProbeMode,
53
+ openRouterDashboardOverlay,
54
+ restartRouterDashboardDaemon,
55
+ toggleRouterDashboardProbePause,
56
+ } from './router-dashboard.js'
45
57
 
46
58
  // 📖 Some providers need an explicit probe model because the first catalog entry
47
59
  // 📖 is not guaranteed to be accepted by their chat endpoint.
48
60
  const PROVIDER_TEST_MODEL_OVERRIDES = {
49
- sambanova: ['DeepSeek-V3-0324'],
50
- nvidia: ['deepseek-ai/deepseek-v3.1-terminus', 'openai/gpt-oss-120b'],
61
+ sambanova: ['MiniMax-M2.5', 'DeepSeek-V3.1', 'DeepSeek-V3.2'],
62
+ nvidia: ['deepseek-ai/deepseek-v4-flash', 'openai/gpt-oss-120b'],
63
+ 'github-models': ['openai/gpt-4.1-mini'],
64
+ mistral: ['mistral-small-latest', 'devstral-small-latest'],
51
65
  }
52
66
 
53
67
  // 📖 Settings key tests retry retryable failures across several models so a
@@ -67,6 +81,7 @@ const PROVIDER_AUTH_ENDPOINTS = {
67
81
  cerebras: { url: 'https://api.cerebras.ai/v1/models', method: 'GET' },
68
82
  sambanova: { url: 'https://api.sambanova.ai/v1/models', method: 'GET' },
69
83
  openrouter: { url: 'https://openrouter.ai/api/v1/models', method: 'GET' },
84
+ mistral: { url: 'https://api.mistral.ai/v1/models', method: 'GET' },
70
85
  huggingface: { url: 'https://router.huggingface.co/v1/models', method: 'GET' },
71
86
  deepinfra: { url: 'https://api.deepinfra.com/v1/models', method: 'GET' },
72
87
  fireworks: { url: 'https://api.fireworks.ai/v1/models', method: 'GET' },
@@ -79,6 +94,7 @@ const PROVIDER_AUTH_ENDPOINTS = {
79
94
  ovhcloud: { url: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1/models', method: 'GET' },
80
95
  qwen: { url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models', method: 'GET' },
81
96
  iflow: { url: 'https://apis.iflow.cn/v1/models', method: 'GET' },
97
+ 'github-models': null, // 📖 GitHub Models catalog is public; use chat ping to validate the token.
82
98
  replicate: null, // 📖 Replicate has no /models endpoint; use chat completions ping
83
99
  cloudflare: null, // 📖 Workers AI has no auth-check endpoint; use ping only
84
100
  zai: null, // 📖 ZAI undocumented; use ping only
@@ -275,7 +291,6 @@ export function createKeyHandler(ctx) {
275
291
  sendUsageTelemetry,
276
292
  startRecommendAnalysis,
277
293
  stopRecommendAnalysis,
278
- sendBugReport,
279
294
  stopUi,
280
295
  ping,
281
296
  getPingModel,
@@ -363,6 +378,40 @@ export function createKeyHandler(ctx) {
363
378
  })
364
379
  }
365
380
 
381
+ async function syncFavoritesToRouter(selected) {
382
+ if (state.config?.router?.enabled !== true) return
383
+ const favorites = state.config.favorites || []
384
+ const selKey = toFavoriteKey(selected.providerKey, selected.modelId)
385
+ const chain = [selKey, ...favorites.filter((f) => f !== selKey)]
386
+ const models = chain.map((f, i) => {
387
+ const slashIdx = f.indexOf('/')
388
+ const provider = slashIdx >= 0 ? f.slice(0, slashIdx) : '?'
389
+ const model = slashIdx >= 0 ? f.slice(slashIdx + 1) : f
390
+ return { provider, model, priority: i + 1 }
391
+ })
392
+ try {
393
+ const port = await readDaemonPort()
394
+ if (!port) return
395
+ const baseUrl = `http://127.0.0.1:${port}`
396
+ const setPayload = { name: 'fast-coding', models, created: new Date().toISOString() }
397
+ await globalThis.fetch(`${baseUrl}/sets/fast-coding`, {
398
+ method: 'PUT',
399
+ headers: { 'Content-Type': 'application/json' },
400
+ body: JSON.stringify(setPayload),
401
+ })
402
+ await globalThis.fetch(`${baseUrl}/sets/fast-coding/activate`, { method: 'POST' })
403
+ } catch {}
404
+ }
405
+
406
+ async function readDaemonPort() {
407
+ try {
408
+ const { readFileSync } = await import('node:fs')
409
+ const raw = readFileSync(`${process.env.HOME}/.free-coding-models-daemon.port`, 'utf8').trim()
410
+ if (/^\d+$/.test(raw)) return Number(raw)
411
+ } catch {}
412
+ return null
413
+ }
414
+
366
415
  async function launchSelectedModel(selected, options = {}) {
367
416
  const { uiAlreadyStopped = false } = options
368
417
  userSelected = { modelId: selected.modelId, label: selected.label, tier: selected.tier, providerKey: selected.providerKey }
@@ -373,7 +422,9 @@ export function createKeyHandler(ctx) {
373
422
  stopUi()
374
423
  }
375
424
 
376
- // 📖 Show selection status before handing control to the target tool.
425
+ // 📖 If router is enabled, push [selected, ...favorites] to daemon as the active set
426
+ await syncFavoritesToRouter(selected)
427
+
377
428
  if (selected.status === 'timeout') {
378
429
  console.log(chalk.yellow(` ⚠ Selected: ${selected.label} (currently timing out)`))
379
430
  } else if (selected.status === 'down') {
@@ -898,12 +949,7 @@ export function createKeyHandler(ctx) {
898
949
  state.installEndpointsResult = null
899
950
  }
900
951
 
901
- function openFeedbackOverlay() {
902
- state.feedbackOpen = true
903
- state.bugReportBuffer = ''
904
- state.bugReportStatus = 'idle'
905
- state.bugReportError = null
906
- }
952
+
907
953
 
908
954
  function openChangelogOverlay() {
909
955
  state.changelogOpen = true
@@ -928,6 +974,51 @@ export function createKeyHandler(ctx) {
928
974
  }
929
975
  }
930
976
 
977
+
978
+ // 📖 Token Usage screen — Shift+T from main table. Fetches daily token history
979
+ // 📖 from the daemon and renders a 7-day chart plus today/all-time breakdowns.
980
+ async function openTokenUsageOverlay() {
981
+ state.tokenUsageOpen = true
982
+ state.tokenUsageScrollOffset = 0
983
+ state.tokenUsageError = null
984
+ state.tokenUsageData = null
985
+ // 📖 Discover daemon port
986
+ let port = 19280
987
+ try {
988
+ const { readFileSync: rfs } = await import('node:fs')
989
+ const portPath = `${process.env.HOME}/.free-coding-models-daemon.port`
990
+ const savedPort = rfs(portPath, 'utf8').trim()
991
+ if (/^\d+$/.test(savedPort)) port = Number(savedPort)
992
+ } catch {}
993
+ state.tokenUsageLastFetchAt = Date.now()
994
+ try {
995
+ const controller = new AbortController()
996
+ const timer = setTimeout(() => controller.abort(), 2000)
997
+ const res = await globalThis.fetch(`http://127.0.0.1:${port}/stats/tokens`, { signal: controller.signal })
998
+ clearTimeout(timer)
999
+ if (!res.ok) {
1000
+ state.tokenUsageError = `Daemon returned HTTP ${res.status} — is the router running?`
1001
+ return
1002
+ }
1003
+ // 📖 Guard: res.json() can throw on malformed response body
1004
+ const text = await res.text()
1005
+ try {
1006
+ state.tokenUsageData = JSON.parse(text)
1007
+ } catch {
1008
+ state.tokenUsageError = 'Daemon returned invalid JSON — try restarting the daemon'
1009
+ }
1010
+ } catch (err) {
1011
+ state.tokenUsageError = err?.name === 'AbortError' ? 'Request timed out — is the router daemon running?' : (err?.message || 'Failed to fetch token stats')
1012
+ }
1013
+ }
1014
+
1015
+ function closeTokenUsageOverlay() {
1016
+ state.tokenUsageOpen = false
1017
+ state.tokenUsageScrollOffset = 0
1018
+ state.tokenUsageError = null
1019
+ }
1020
+
1021
+
931
1022
  function cycleToolMode() {
932
1023
  const modeOrder = getToolModeOrder()
933
1024
  const currentIndex = modeOrder.indexOf(state.mode)
@@ -1016,8 +1107,9 @@ export function createKeyHandler(ctx) {
1016
1107
  || state.installEndpointsOpen
1017
1108
  || state.toolInstallPromptOpen
1018
1109
  || state.installedModelsOpen
1110
+ || state.routerDashboardOpen
1019
1111
  || state.recommendOpen
1020
- || state.feedbackOpen
1112
+
1021
1113
  || state.helpVisible
1022
1114
  || state.changelogOpen
1023
1115
  }
@@ -1205,8 +1297,10 @@ export function createKeyHandler(ctx) {
1205
1297
  state.helpScrollOffset = 0
1206
1298
  return
1207
1299
  case 'open-changelog': return openChangelogOverlay()
1208
- case 'open-feedback': return openFeedbackOverlay()
1300
+
1209
1301
  case 'open-recommend': return openRecommendOverlay()
1302
+ case 'open-router-dashboard': return openRouterDashboardOverlay(state)
1303
+ case 'open-token-usage': return openTokenUsageOverlay()
1210
1304
  case 'open-install-endpoints': return openInstallEndpointsOverlay()
1211
1305
  case 'open-installed-models': return openInstalledModelsOverlay()
1212
1306
  case 'action-cycle-theme': return cycleGlobalTheme()
@@ -1333,13 +1427,66 @@ export function createKeyHandler(ctx) {
1333
1427
  return
1334
1428
  }
1335
1429
 
1336
- if (!state.feedbackOpen && !state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
1430
+ if (!state.settingsEditMode && !state.settingsAddKeyMode && key.name === 'g' && !key.ctrl && !key.meta) {
1337
1431
  cycleGlobalTheme()
1338
1432
  return
1339
1433
  }
1340
1434
 
1341
1435
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1342
1436
 
1437
+ // 📖 Router Dashboard captures local dashboard controls while open so keys
1438
+ // 📖 like S/I/R/C do not leak through to sort/filter actions in the table.
1439
+ if (state.routerDashboardOpen) {
1440
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1441
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1442
+
1443
+ if (key.name === 'escape') {
1444
+ closeRouterDashboardOverlay(state)
1445
+ return
1446
+ }
1447
+ if (key.name === 'up' || key.name === 'k') {
1448
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) - 1)
1449
+ return
1450
+ }
1451
+ if (key.name === 'down' || key.name === 'j') {
1452
+ state.routerDashboardScrollOffset = (state.routerDashboardScrollOffset || 0) + 1
1453
+ return
1454
+ }
1455
+ if (key.name === 'pageup') {
1456
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) - pageStep)
1457
+ return
1458
+ }
1459
+ if (key.name === 'pagedown') {
1460
+ state.routerDashboardScrollOffset = (state.routerDashboardScrollOffset || 0) + pageStep
1461
+ return
1462
+ }
1463
+ if (key.name === 'home') {
1464
+ state.routerDashboardScrollOffset = 0
1465
+ return
1466
+ }
1467
+ if (key.name === 's') {
1468
+ try { await cycleRouterDashboardActiveSet(state) } catch {}
1469
+ return
1470
+ }
1471
+ if (key.name === 'i') {
1472
+ try { await cycleRouterDashboardProbeMode(state) } catch {}
1473
+ return
1474
+ }
1475
+ if (key.name === 'r') {
1476
+ restartRouterDashboardDaemon(state)
1477
+ return
1478
+ }
1479
+ if (key.name === 'c') {
1480
+ clearRouterDashboardRequestLog(state)
1481
+ return
1482
+ }
1483
+ if (key.name === 'p') {
1484
+ toggleRouterDashboardProbePause(state)
1485
+ return
1486
+ }
1487
+ return
1488
+ }
1489
+
1343
1490
  // 📖 Install Endpoints overlay: provider → tool → connection → scope → optional model subset.
1344
1491
  if (state.installEndpointsOpen) {
1345
1492
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -1736,87 +1883,132 @@ export function createKeyHandler(ctx) {
1736
1883
  return
1737
1884
  }
1738
1885
 
1739
- // 📖 Feedback overlay: intercept ALL keys while overlay is active.
1740
- // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
1741
- if (state.feedbackOpen) {
1886
+
1887
+
1888
+ // 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
1889
+ if (state.helpVisible) {
1890
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
1891
+ if (key.name === 'escape' || (key.ctrl && key.name === 'h')) {
1892
+ state.helpVisible = false
1893
+ return
1894
+ }
1895
+ if (key.name === 'up' || key.name === 'k') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
1896
+ if (key.name === 'down' || key.name === 'j') { state.helpScrollOffset += 1; return }
1897
+ if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
1898
+ if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
1899
+ if (key.name === 'home') { state.helpScrollOffset = 0; return }
1900
+ if (key.name === 'end') { state.helpScrollOffset = Number.MAX_SAFE_INTEGER; return }
1742
1901
  if (key.ctrl && key.name === 'c') { exit(0); return }
1902
+ return
1903
+ }
1743
1904
 
1905
+ // 📖 Token Usage overlay: Shift+T shows token history chart and today/all-time breakdowns.
1906
+ if (state.tokenUsageOpen) {
1907
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1908
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1744
1909
  if (key.name === 'escape') {
1745
- // 📖 Cancel feedback — close overlay
1746
- state.feedbackOpen = false
1747
- state.bugReportBuffer = ''
1748
- state.bugReportStatus = 'idle'
1749
- state.bugReportError = null
1910
+ closeTokenUsageOverlay()
1911
+ return
1912
+ }
1913
+ if (key.name === 'up' || key.name === 'k') {
1914
+ state.tokenUsageScrollOffset = Math.max(0, state.tokenUsageScrollOffset - 1)
1915
+ return
1916
+ }
1917
+ if (key.name === 'down' || key.name === 'j') {
1918
+ state.tokenUsageScrollOffset += 1
1919
+ return
1920
+ }
1921
+ if (key.name === 'pageup') {
1922
+ state.tokenUsageScrollOffset = Math.max(0, state.tokenUsageScrollOffset - pageStep)
1923
+ return
1924
+ }
1925
+ if (key.name === 'pagedown') {
1926
+ state.tokenUsageScrollOffset += pageStep
1750
1927
  return
1751
1928
  }
1929
+ if (key.name === 'home') {
1930
+ state.tokenUsageScrollOffset = 0
1931
+ return
1932
+ }
1933
+ if (key.name === 'end') {
1934
+ state.tokenUsageScrollOffset = Number.MAX_SAFE_INTEGER
1935
+ return
1936
+ }
1937
+ return
1938
+ }
1752
1939
 
1753
- if (key.name === 'return') {
1754
- // 📖 Send feedback to Discord webhook
1755
- const message = state.bugReportBuffer.trim()
1756
- if (message.length > 0 && state.bugReportStatus !== 'sending') {
1757
- state.bugReportStatus = 'sending'
1758
- const result = await sendBugReport(message)
1759
- if (result.success) {
1760
- // 📖 Success — show confirmation briefly, then close overlay after 3 seconds
1761
- state.bugReportStatus = 'success'
1762
- setTimeout(() => {
1763
- state.feedbackOpen = false
1764
- state.bugReportBuffer = ''
1765
- state.bugReportStatus = 'idle'
1766
- state.bugReportError = null
1767
- }, 3000)
1768
- } else {
1769
- // 📖 Error — show error message, keep overlay open
1770
- state.bugReportStatus = 'error'
1771
- state.bugReportError = result.error || 'Unknown error'
1940
+ // 📖 Router Onboarding overlay: shown on first launch. Y=yes enable, N=not now, Esc=cancel.
1941
+ if (state.routerOnboardingOpen) {
1942
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1943
+ if (state.routerOnboardingPhase === 'loading' || state.routerOnboardingPhase === 'success' || state.routerOnboardingPhase === 'error') {
1944
+ if (key.name === 'escape' || key.name === 'return') {
1945
+ state.routerOnboardingOpen = false
1946
+ // 📖 Mark onboarding as seen (don't show again)
1947
+ if (state.config?.router) {
1948
+ state.config.router.onboardingSeen = true
1772
1949
  }
1950
+ return
1773
1951
  }
1774
1952
  return
1775
1953
  }
1776
-
1777
- if (key.name === 'backspace') {
1778
- // 📖 Don't allow editing while sending or after success
1779
- if (state.bugReportStatus === 'sending' || state.bugReportStatus === 'success') return
1780
- state.bugReportBuffer = state.bugReportBuffer.slice(0, -1)
1781
- // 📖 Clear error status when user starts editing again
1782
- if (state.bugReportStatus === 'error') {
1783
- state.bugReportStatus = 'idle'
1784
- state.bugReportError = null
1954
+ if (key.name === 'escape' || key.name === 'n') {
1955
+ state.routerOnboardingOpen = false
1956
+ // 📖 Mark as seen and disabled
1957
+ if (state.config?.router) {
1958
+ state.config.router.onboardingSeen = true
1959
+ state.config.router.enabled = false
1785
1960
  }
1786
1961
  return
1787
1962
  }
1788
-
1789
- // 📖 Append printable characters (str is the raw character typed)
1790
- // 📖 Limit to 500 characters (Discord embed description limit)
1791
- if (str && str.length === 1 && !key.ctrl && !key.meta) {
1792
- // 📖 Don't allow editing while sending or after success
1793
- if (state.bugReportStatus === 'sending' || state.bugReportStatus === 'success') return
1794
- if (state.bugReportBuffer.length < 500) {
1795
- state.bugReportBuffer += str
1796
- // 📖 Clear error status when user starts editing again
1797
- if (state.bugReportStatus === 'error') {
1798
- state.bugReportStatus = 'idle'
1799
- state.bugReportError = null
1963
+ if (key.name === 'up' || key.name === 'k') {
1964
+ state.routerOnboardingCursor = 0
1965
+ return
1966
+ }
1967
+ if (key.name === 'down' || key.name === 'j') {
1968
+ state.routerOnboardingCursor = 1
1969
+ return
1970
+ }
1971
+ if (key.name === 'return' || key.name === 'y') {
1972
+ const shouldEnable = key.name === 'return' ? true : (state.routerOnboardingCursor === 0)
1973
+ if (!shouldEnable) {
1974
+ state.routerOnboardingOpen = false
1975
+ if (state.config?.router) {
1976
+ state.config.router.onboardingSeen = true
1977
+ state.config.router.enabled = false
1800
1978
  }
1979
+ return
1801
1980
  }
1802
- }
1803
- return
1804
- }
1805
-
1806
- // 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
1807
- if (state.helpVisible) {
1808
- const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
1809
- if (key.name === 'escape' || (key.ctrl && key.name === 'h')) {
1810
- state.helpVisible = false
1981
+ // 📖 Enable router: start daemon in background and mark onboarding seen
1982
+ state.routerOnboardingPhase = 'loading'
1983
+ state.routerOnboardingError = null
1984
+ void (async () => {
1985
+ try {
1986
+ const binPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'free-coding-models.js')
1987
+ const child = spawn('node', [binPath, '--daemon-bg'], {
1988
+ detached: true,
1989
+ stdio: 'ignore',
1990
+ })
1991
+ child.unref()
1992
+ await new Promise((r) => setTimeout(r, 2000))
1993
+ if (state.routerOnboardingPhase === 'loading') {
1994
+ state.routerOnboardingPhase = 'success'
1995
+ if (state.config?.router) {
1996
+ state.config.router.enabled = true
1997
+ state.config.router.onboardingSeen = true
1998
+ saveConfig(state.config)
1999
+ }
2000
+ trackTelemetryEvent('app_router_install', { router_version: '0.4.0' })
2001
+ await new Promise((r) => setTimeout(r, 1500))
2002
+ state.routerOnboardingOpen = false
2003
+ openRouterDashboardOverlay(state)
2004
+ }
2005
+ } catch (err) {
2006
+ state.routerOnboardingPhase = 'error'
2007
+ state.routerOnboardingError = err?.message || 'Failed to start router'
2008
+ }
2009
+ })()
1811
2010
  return
1812
2011
  }
1813
- if (key.name === 'up' || key.name === 'k') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - 1); return }
1814
- if (key.name === 'down' || key.name === 'j') { state.helpScrollOffset += 1; return }
1815
- if (key.name === 'pageup') { state.helpScrollOffset = Math.max(0, state.helpScrollOffset - pageStep); return }
1816
- if (key.name === 'pagedown') { state.helpScrollOffset += pageStep; return }
1817
- if (key.name === 'home') { state.helpScrollOffset = 0; return }
1818
- if (key.name === 'end') { state.helpScrollOffset = Number.MAX_SAFE_INTEGER; return }
1819
- if (key.ctrl && key.name === 'c') { exit(0); return }
1820
2012
  return
1821
2013
  }
1822
2014
 
@@ -2366,9 +2558,16 @@ export function createKeyHandler(ctx) {
2366
2558
 
2367
2559
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2368
2560
 
2369
- // 📖 Shift+R: reset all UI view settings to defaults (tier, sort, provider) and clear persisted config
2370
- if (key.name === 'r' && key.shift) {
2371
- resetViewSettings()
2561
+ // 📖 Shift+R intentionally stays unadvertised in the main UI, but remains
2562
+ // 📖 available as a tester entry point for the Router Dashboard.
2563
+ if (key.name === 'r' && key.shift && !key.ctrl && !key.meta) {
2564
+ openRouterDashboardOverlay(state)
2565
+ return
2566
+ }
2567
+
2568
+ // 📖 Shift+T: open the Token Usage screen.
2569
+ if (key.name === 't' && key.shift && !key.ctrl && !key.meta) {
2570
+ openTokenUsageOverlay()
2372
2571
  return
2373
2572
  }
2374
2573
 
@@ -2383,7 +2582,7 @@ export function createKeyHandler(ctx) {
2383
2582
  // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
2384
2583
  // 📖 X clears the active custom text filter.
2385
2584
  // 📖 D is now reserved for provider filter cycling
2386
- // 📖 Shift+R is reserved for reset view settings
2585
+ // 📖 Shift+R is reserved for the Router Dashboard; reset view remains in Ctrl+P.
2387
2586
  const sortKeys = {
2388
2587
  'r': 'rank', 'o': 'origin', 'm': 'model',
2389
2588
  'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
@@ -2401,11 +2600,7 @@ export function createKeyHandler(ctx) {
2401
2600
  return
2402
2601
  }
2403
2602
 
2404
- // 📖 I key: open Feedback overlay (anonymous Discord feedback)
2405
- if (key.name === 'i') {
2406
- openFeedbackOverlay()
2407
- return
2408
- }
2603
+
2409
2604
 
2410
2605
  // 📖 W cycles the supported ping modes:
2411
2606
  // 📖 speed (2s) → normal (10s) → slow (30s) → forced (4s) → speed.
@@ -2417,16 +2612,6 @@ export function createKeyHandler(ctx) {
2417
2612
  return
2418
2613
  }
2419
2614
 
2420
- // 📖 Ctrl+O: toggle footer visibility (collapse to single hint when hidden)
2421
- if (key.ctrl && key.name === 'o' && !key.meta) {
2422
- state.footerHidden = !state.footerHidden
2423
- if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
2424
- state.config.settings.footerHidden = state.footerHidden
2425
- saveConfig(state.config)
2426
- state.frame++ // 📖 Force immediate re-render
2427
- return
2428
- }
2429
-
2430
2615
  // 📖 E toggles hiding models whose provider has no configured API key.
2431
2616
  // 📖 The preference is saved globally.
2432
2617
  if (key.name === 'e') {
@@ -2477,8 +2662,8 @@ export function createKeyHandler(ctx) {
2477
2662
  return
2478
2663
  }
2479
2664
 
2480
- // 📖 Help overlay key: Ctrl+H = toggle help overlay
2481
- if (key.ctrl && key.name === 'h') {
2665
+ // 📖 Help overlay key: I = toggle help overlay
2666
+ if (key.name === 'i') {
2482
2667
  state.helpVisible = !state.helpVisible
2483
2668
  if (state.helpVisible) state.helpScrollOffset = 0
2484
2669
  return
@@ -2502,6 +2687,28 @@ export function createKeyHandler(ctx) {
2502
2687
  return
2503
2688
  }
2504
2689
 
2690
+ if (key.shift && key.name === 'up') {
2691
+ const selected = state.visibleSorted?.[state.cursor]
2692
+ if (selected?.isFavorite) {
2693
+ reorderFavorite(state.config, selected.providerKey, selected.modelId, 'up')
2694
+ syncFavoriteFlags(state.results, state.config)
2695
+ applyTierFilter()
2696
+ refreshVisibleSorted({ resetCursor: false })
2697
+ }
2698
+ return
2699
+ }
2700
+
2701
+ if (key.shift && key.name === 'down') {
2702
+ const selected = state.visibleSorted?.[state.cursor]
2703
+ if (selected?.isFavorite) {
2704
+ reorderFavorite(state.config, selected.providerKey, selected.modelId, 'down')
2705
+ syncFavoriteFlags(state.results, state.config)
2706
+ applyTierFilter()
2707
+ refreshVisibleSorted({ resetCursor: false })
2708
+ }
2709
+ return
2710
+ }
2711
+
2505
2712
  if (key.name === 'up' || key.name === 'k') {
2506
2713
  // 📖 Main list wrap navigation: top -> bottom on Up / K (vim-style).
2507
2714
  const count = state.visibleSorted.length
@@ -2527,12 +2734,9 @@ export function createKeyHandler(ctx) {
2527
2734
  }
2528
2735
 
2529
2736
  if (key.name === 'return') { // Enter
2530
- // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
2531
2737
  const selected = state.visibleSorted[state.cursor]
2532
- if (!selected) return // 📖 Guard: empty visible list (all filtered out)
2738
+ if (!selected) return
2533
2739
 
2534
- // 📖 Incompatibility intercept — if the model can't run on the active tool,
2535
- // 📖 show the fallback overlay instead of launching. Lets user switch tool or pick similar model.
2536
2740
  if (!isModelCompatibleWithTool(selected.providerKey, state.mode)) {
2537
2741
  const compatTools = getCompatibleTools(selected.providerKey)
2538
2742
  const similarModels = findSimilarCompatibleModels(
@@ -2714,10 +2918,7 @@ export function createMouseEventHandler(ctx) {
2714
2918
  }
2715
2919
  return
2716
2920
  }
2717
- if (state.feedbackOpen) {
2718
- // 📖 Feedback overlay doesn't scroll — ignore
2719
- return
2720
- }
2921
+
2721
2922
  if (state.commandPaletteOpen) {
2722
2923
  // 📖 Command palette: scroll the results list
2723
2924
  const count = state.commandPaletteResults?.length || 0
@@ -2763,6 +2964,11 @@ export function createMouseEventHandler(ctx) {
2763
2964
  }
2764
2965
  return
2765
2966
  }
2967
+ if (state.routerDashboardOpen) {
2968
+ const step = evt.type === 'scroll-up' ? -3 : 3
2969
+ state.routerDashboardScrollOffset = Math.max(0, (state.routerDashboardScrollOffset || 0) + step)
2970
+ return
2971
+ }
2766
2972
 
2767
2973
  // 📖 Main table scroll: move cursor up/down with wrap-around
2768
2974
  const count = state.visibleSorted.length
@@ -2843,18 +3049,18 @@ export function createMouseEventHandler(ctx) {
2843
3049
  return
2844
3050
  }
2845
3051
 
3052
+ if (state.routerDashboardOpen) {
3053
+ closeRouterDashboardOverlay(state)
3054
+ return
3055
+ }
3056
+
2846
3057
  if (state.incompatibleFallbackOpen) {
2847
3058
  // 📖 Incompatible fallback: click closes
2848
3059
  state.incompatibleFallbackOpen = false
2849
3060
  return
2850
3061
  }
2851
3062
 
2852
- if (state.feedbackOpen) {
2853
- // 📖 Feedback overlay: click anywhere closes (no scroll, no cursor)
2854
- state.feedbackOpen = false
2855
- state.feedbackInput = ''
2856
- return
2857
- }
3063
+
2858
3064
 
2859
3065
  if (state.helpVisible) {
2860
3066
  // 📖 Help overlay: click anywhere closes (same as K or Escape)
@@ -3004,6 +3210,8 @@ export function createMouseEventHandler(ctx) {
3004
3210
  // 📖 Most are single-character keys; special cases like ctrl+p need special handling.
3005
3211
  if (zone.key === 'ctrl+p') {
3006
3212
  process.stdin.emit('keypress', '\x10', { name: 'p', ctrl: true, meta: false, shift: false })
3213
+ } else if (zone.key === 'shift+r') {
3214
+ process.stdin.emit('keypress', 'R', { name: 'r', ctrl: false, meta: false, shift: true })
3007
3215
  } else {
3008
3216
  process.stdin.emit('keypress', zone.key, { name: zone.key, ctrl: false, meta: false, shift: false })
3009
3217
  }