free-coding-models 0.3.55 → 0.3.56

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 (73) hide show
  1. package/CHANGELOG.md +47 -56
  2. package/README.md +236 -160
  3. package/bin/free-coding-models.js +46 -0
  4. package/package.json +2 -2
  5. package/sources.js +133 -309
  6. package/src/analysis.js +23 -10
  7. package/src/app.js +113 -7
  8. package/src/cache.js +1 -1
  9. package/src/cli-help.js +9 -0
  10. package/src/command-palette.js +16 -12
  11. package/src/config.js +199 -32
  12. package/src/endpoint-installer.js +45 -1
  13. package/src/favorites.js +22 -0
  14. package/src/graphify-out/cache/089db1c1def873cf6d112f1590da4490e61e691aff0db41e006aa2fb15ba0656.json +1 -0
  15. package/src/graphify-out/cache/0b510b53cf1a1393fb52b1fc3bbbf88b63938e961ec5b82119a2e9715fee8bd7.json +1 -0
  16. package/src/graphify-out/cache/0ec9a95a326bde58e0316889018b278062d06d494d0f31ba177c9de71e5fed2d.json +1 -0
  17. package/src/graphify-out/cache/1548663a24a68dce740ebab1bd1d3091048c9604e9d067a1650a42a6d82541d4.json +1 -0
  18. package/src/graphify-out/cache/1783af63cb6d0dfb4d469009f71ac83a74ba0b33d48186ff2c6e63f9429e900a.json +1 -0
  19. package/src/graphify-out/cache/1e109f5eb5dc4fd285871c3613e32b6b14a8c225f4080ee34b51c7e1a1764571.json +1 -0
  20. package/src/graphify-out/cache/1eb24dbeb69b46c8bc1caf925df2f2a964af0f33aea143adf8ddf88e017db6ca.json +1 -0
  21. package/src/graphify-out/cache/21e1bcfed11685e8347243f9d8516072dda183266a4bfe22c52fb31753a446c8.json +1 -0
  22. package/src/graphify-out/cache/2327473478b9c4b1940bf7ef66c9ee960b3cba8d5302e56b625df8274246e0b4.json +1 -0
  23. package/src/graphify-out/cache/25955b81fd25454c8fa90fb71a47db8d1215cf621beb8ff3cbd580aaf011b4f3.json +1 -0
  24. package/src/graphify-out/cache/2739677f19c702f88f3de0a0bac475066adbda98709907ad3de967aef689f86d.json +1 -0
  25. package/src/graphify-out/cache/2bba03422f6b3ee7f5b5d29cc90314a064d259e5822a176657bda3e04505cf00.json +1 -0
  26. package/src/graphify-out/cache/2ddf1d2c6d10147b0402446bc71a7988187b79b6210dd7e7250be8c555b9ff35.json +1 -0
  27. package/src/graphify-out/cache/2ee07457a5767c95a57f8e9eb95b28f800044f35666e0715e9d88ad1103a092e.json +1 -0
  28. package/src/graphify-out/cache/2fe9f75dc2951c417f2c8dd22749092cf550dc67599f1c8d1866900dc6e9154e.json +1 -0
  29. package/src/graphify-out/cache/41c4b7c27e7fc3e2948d3a4bf95a72de2ed9a6f0463994babdce8ed2cc84598c.json +1 -0
  30. package/src/graphify-out/cache/5028defd54b7fbd3c7e444973e493de036e097e9b1d2a7cae7f19b88d68aacde.json +1 -0
  31. package/src/graphify-out/cache/5b133aba3fb16410c5b1fdbd1730039fc7fa1ac93abd99d7be08f60da70fc8d4.json +1 -0
  32. package/src/graphify-out/cache/74252e5b0978d85ab3421a3de1a9384aa282ffd2be2cfe7db2530139089f4275.json +1 -0
  33. package/src/graphify-out/cache/7695ebeea056095edd14332963cc43354ef3a097caf46f1e28d0f01369642901.json +1 -0
  34. package/src/graphify-out/cache/777aa7085c395a935c6556bbde182cd871edb61f3a685ed8068ec0c8f6fb0075.json +1 -0
  35. package/src/graphify-out/cache/82a723881980e82273c113def8315533d7da28827e300413d9ad30f27b7407df.json +1 -0
  36. package/src/graphify-out/cache/86b87c9603e6cd188f42c7eed3b86c291d48a781c223a707e74f3e7ed0c02a21.json +1 -0
  37. package/src/graphify-out/cache/890fead9a78cadaed560a2d2453916121fa605c3e43a334910ac4bc951a9ef6d.json +1 -0
  38. package/src/graphify-out/cache/89d3ea66f52783caa775ef9a30923d7d6225e1d8ae9e962f4741b8c7785dab1e.json +1 -0
  39. package/src/graphify-out/cache/8cc82cd9edce41f0e1c092f14a94fd52bf847addf3237b616dc5a9e505bd05bd.json +1 -0
  40. package/src/graphify-out/cache/93ba2e25e3ff7ad525f397902345fbd375df7315de7b402e20cc803c14eccde8.json +1 -0
  41. package/src/graphify-out/cache/99beed29580b9c7bfecfee794cb3d8e535fcf0eb3b92113108f88bdd0a8e79b3.json +1 -0
  42. package/src/graphify-out/cache/aeeb931fa477c65ce2e51d8149957350fa54225c613222bbbe8448998d1afd3d.json +1 -0
  43. package/src/graphify-out/cache/baf91bef5b5ecb2a476433b6cc0c48c563c54ee2d07fc3c192e543685e3e7222.json +1 -0
  44. package/src/graphify-out/cache/bd98b94ac4e9b92b6336d47b26e0366b51a4eaf0711d722f05f98dfae23ab42b.json +1 -0
  45. package/src/graphify-out/cache/bfcb51e9328e9cbfbee4f6fee0f56635d7b03488addc9f6c4e4b190b70a73362.json +1 -0
  46. package/src/graphify-out/cache/c0d3dabeb093aa758c49eadf41b87ecc96a16c1449c2670aaf48cbfc891d8da6.json +1 -0
  47. package/src/graphify-out/cache/c20d6630236f473c1406068c3ae205853e649b216495c93dfec055dd222c55cf.json +1 -0
  48. package/src/graphify-out/cache/c22b9122816bebce0a2f79af41a986559d01e00163dbcd579c5755621b4cb483.json +1 -0
  49. package/src/graphify-out/cache/ca556ec14453ddb8f9e0c5a832dac90d77111b9bad5f8c2d80d272e2e7a06371.json +1 -0
  50. package/src/graphify-out/cache/d6dbc9135dfa35a756b3b09b06700e4bc229fdccba11bb963f2ba44028e0bbae.json +1 -0
  51. package/src/graphify-out/cache/e1cf71276f1779d0fa075f79bd7c8a9fd0b8eef6932ac043137451b7c7fa7cbe.json +1 -0
  52. package/src/graphify-out/cache/e4b3be14494467df2d2ed389bc4f18f099021cb5bc355b901fa88387b2d8b8a2.json +1 -0
  53. package/src/graphify-out/cache/eaea0dded097f6f9553b654220046c6ec0c9be592a5973d906564ee60af34e0d.json +1 -0
  54. package/src/graphify-out/cache/ef07d0cd2675d1f79d2a2fdbf3bc3319687638751e9ce89b0d0d97ed1cd9f7e1.json +1 -0
  55. package/src/graphify-out/cache/f81272d6eb8aaff9e96d5a1d9f06777db70ac3652a646b951ded51f79871d733.json +1 -0
  56. package/src/graphify-out/cache/f9619dd92186f75a6dbda937e0c606647153918524cdb5763f956e6ec2a9e386.json +1 -0
  57. package/src/graphify-out/cache/fd88b1b2ff4bfcae08559d9c2aaeeb9a3f1e2f5cd8928762c311196956c170a5.json +1 -0
  58. package/src/key-handler.js +312 -12
  59. package/src/kilo.js +20 -1
  60. package/src/opencode.js +23 -2
  61. package/src/overlays.js +206 -5
  62. package/src/provider-metadata.js +26 -17
  63. package/src/quota-capabilities.js +6 -10
  64. package/src/render-table.js +37 -4
  65. package/src/router-daemon.js +1986 -0
  66. package/src/router-dashboard.js +893 -0
  67. package/src/sync-set.js +479 -0
  68. package/src/theme.js +4 -0
  69. package/src/tool-launchers.js +1 -0
  70. package/src/tool-metadata.js +6 -2
  71. package/src/utils.js +30 -6
  72. package/web/dist/assets/{index-C03JjCgA.js → index-DNRCaWPi.js} +2 -2
  73. package/web/dist/index.html +1 -1
package/src/overlays.js CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * @details
6
6
  * This module centralizes all overlay rendering in one place:
7
- * - Settings, Install Endpoints, Command Palette, Help, Smart Recommend, Feedback, Changelog
7
+ * - Settings, Install Endpoints, Command Palette, Help, Smart Recommend, Feedback, Changelog, Router Dashboard
8
8
  * - Settings diagnostics for provider key tests, including wrapped retry/error details
9
9
  * - Recommend analysis timer orchestration and progress updates
10
10
  *
@@ -15,6 +15,7 @@
15
15
  *
16
16
  * → Functions:
17
17
  * - `createOverlayRenderers` — returns renderer + analysis helpers + overlayLayout
18
+ * - `renderRouterDashboard` — mounts the Smart Model Router dashboard renderer
18
19
  *
19
20
  * @exports { createOverlayRenderers }
20
21
  * @see ./key-handler.js — handles keypresses for all overlay interactions
@@ -22,6 +23,7 @@
22
23
 
23
24
  import { loadChangelog } from './changelog-loader.js'
24
25
  import { buildCliHelpLines } from './cli-help.js'
26
+ import { renderRouterDashboard as renderRouterDashboardOverlay } from './router-dashboard.js'
25
27
  import { themeColors, getThemeStatusLabel, getProviderRgb } from './theme.js'
26
28
 
27
29
  export function createOverlayRenderers(state, deps) {
@@ -882,7 +884,7 @@ export function createOverlayRenderers(state, deps) {
882
884
  lines.push(` ${label('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${hint('Sort:')} ${key('C')}`)
883
885
  lines.push(` ${hint('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
884
886
  lines.push('')
885
- lines.push(` ${label('Model')} Model name ( = favorited) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
887
+ lines.push(` ${label('Model')} Model name (①②③ = favorite order) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
886
888
  lines.push(` ${hint('Star the ones you like. Press Y to switch between pinned mode and normal filter/sort mode.')}`)
887
889
  lines.push('')
888
890
  lines.push(` ${label('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${hint('Sort:')} ${key('O')} ${hint('Cycle:')} ${key('D')}`)
@@ -923,15 +925,17 @@ export function createOverlayRenderers(state, deps) {
923
925
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
924
926
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
925
927
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → π Pi → 🪼 jcode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → ⚡ Amp → 🦘 Rovo → ♊ Gemini)')}`)
926
- lines.push(` ${key('F')} Toggle favorite on selected row ${hint('( persisted across sessions)')}`)
928
+ lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(①②③ = router fallback order)')}`)
929
+ lines.push(` ${key('⇧↑/⇧↓')} Reorder selected favorite up/down ${hint('(changes router priority)')}`)
927
930
  lines.push(` ${key('Y')} Toggle favorites mode ${hint('(Pinned + always visible ↔ Normal filter/sort behavior)')}`)
928
931
  lines.push(` ${key('X')} Clear active text filter ${hint('(remove custom query applied from ⚡️ Command Palette)')}`)
929
932
  lines.push(` ${key('Q')} Smart Recommend ${hint('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
933
+ lines.push(` ${key('Shift+R')} Router Dashboard ${hint('(🔀 daemon health, circuit breakers, tokens, request log)')}`)
930
934
  lines.push(` ${key('G')} Cycle theme ${hint('(auto → dark → light)')}`)
931
935
  lines.push(` ${themeColors.errorBold('I')} Feedback, bugs & requests ${hint('(📝 send anonymous feedback, bug reports, or feature requests)')}`)
932
936
  lines.push(` ${key('P')} Open settings ${hint('(manage API keys, provider toggles, updates, legacy cleanup)')}`)
933
937
  // 📖 Profile system removed - API keys now persist permanently across all sessions
934
- lines.push(` ${key('Shift+R')} Reset view settings ${hint('(tier filter, sort, provider filter defaults)')}`)
938
+ lines.push(` ${key('Ctrl+P')} Reset view settings ${hint('(search "Reset view" in the command palette)')}`)
935
939
  lines.push(` ${key('N')} Changelog ${hint('(📋 browse all versions, Enter to view details)')}`)
936
940
  lines.push(` ${key('Ctrl+H')} / ${key('Esc')} Show/hide this help`)
937
941
  lines.push(` ${key('Ctrl+C')} Exit`)
@@ -1394,6 +1398,10 @@ export function createOverlayRenderers(state, deps) {
1394
1398
  if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
1395
1399
  }
1396
1400
 
1401
+ function renderRouterDashboard() {
1402
+ return renderRouterDashboardOverlay(state, { LOCAL_VERSION })
1403
+ }
1404
+
1397
1405
  // ─── Incompatible fallback overlay ─────────────────────────────────────────
1398
1406
  // 📖 renderIncompatibleFallback shows when user presses Enter on a model that
1399
1407
  // 📖 is NOT compatible with the active tool. Two sections:
@@ -1490,6 +1498,195 @@ export function createOverlayRenderers(state, deps) {
1490
1498
  return cleared.join('\n')
1491
1499
  }
1492
1500
 
1501
+
1502
+ // ─── Token Usage screen renderer ───────────────────────────────────────────
1503
+ // 📖 renderTokenUsage: shows today/all-time breakdowns, by-model breakdown,
1504
+ // 📖 and a 7-day bar chart. Triggered by Shift+T from the main table.
1505
+ // 📖 Data fetched from GET /stats/tokens on the daemon.
1506
+ function renderTokenUsage() {
1507
+ const EL = '\x1b[K'
1508
+ const lines = []
1509
+ const cursorLineByRow = {}
1510
+
1511
+ lines.push('')
1512
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
1513
+ lines.push(` ${themeColors.textBold('📊 Token Usage')} ${themeColors.dim('Shift+T from main table')}`)
1514
+ lines.push('')
1515
+
1516
+ const data = state.tokenUsageData
1517
+
1518
+ if (state.tokenUsageError) {
1519
+ lines.push(` ${themeColors.warning(state.tokenUsageError)}`)
1520
+ lines.push('')
1521
+ lines.push(themeColors.dim(' Press Shift+S to start the router daemon first, then reopen this screen.'))
1522
+ lines.push(themeColors.dim(' Esc to return to the main table'))
1523
+ const { visible, offset } = sliceOverlayLines(lines, state.tokenUsageScrollOffset, state.terminalRows)
1524
+ state.tokenUsageScrollOffset = offset
1525
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1526
+ return tintedLines.map((l) => l + EL).join('\n')
1527
+ }
1528
+
1529
+ if (!data) {
1530
+ lines.push(themeColors.dim(' Loading token stats...'))
1531
+ const { visible, offset } = sliceOverlayLines(lines, state.tokenUsageScrollOffset, state.terminalRows)
1532
+ state.tokenUsageScrollOffset = offset
1533
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1534
+ return tintedLines.map((l) => l + EL).join('\n')
1535
+ }
1536
+
1537
+ const today = data.today || {}
1538
+ const allTime = data.all_time || {}
1539
+ const dailyData = data.daily || {}
1540
+
1541
+ const todayTotal = today.total_tokens || 0
1542
+ const todayPrompt = today.prompt_tokens || 0
1543
+ const todayCompletion = today.completion_tokens || 0
1544
+ const todayReq = today.requests || 0
1545
+ const allTimeTotal = allTime.total_tokens || 0
1546
+ const allTimeReq = allTime.requests || 0
1547
+ const firstTracked = allTime.first_tracked || null
1548
+
1549
+ lines.push(` ${themeColors.textBold('TODAY')} ${themeColors.dim(new Date().toISOString().slice(0, 10))} ${themeColors.dim('|')} ${themeColors.textBold('ALL TIME')}`)
1550
+ lines.push(` ${themeColors.dim('─'.repeat(40))} ${themeColors.dim('─'.repeat(30))}`)
1551
+ lines.push(` ${themeColors.textBold('Total:')} ${themeColors.info(formatTokenTotalCompact(todayTotal))} tok ${themeColors.dim('│')} ${themeColors.textBold('Total:')} ${themeColors.info(formatTokenTotalCompact(allTimeTotal))} tok`)
1552
+ lines.push(` ${themeColors.textBold('Prompt:')} ${themeColors.dim(formatTokenTotalCompact(todayPrompt))} tok ${themeColors.dim('│')} ${themeColors.textBold('Requests:')} ${themeColors.dim(String(allTimeReq))}`)
1553
+ lines.push(` ${themeColors.textBold('Completion:')} ${themeColors.dim(formatTokenTotalCompact(todayCompletion))} tok ${themeColors.dim('│')} ${themeColors.textBold('Since:')} ${themeColors.dim(firstTracked ? new Date(firstTracked).toLocaleDateString() : '—')}`)
1554
+ lines.push(` ${themeColors.textBold('Requests:')} ${themeColors.dim(String(todayReq))} ${themeColors.dim('│')}`)
1555
+
1556
+ const byModel = today.by_model || {}
1557
+ const sortedModels = Object.entries(byModel)
1558
+ .map(([key, val]) => {
1559
+ // 📖 val can be a number (legacy) or { total, prompt, completion } object
1560
+ const total = (val && typeof val === 'object' && !Array.isArray(val)) ? (val.total || 0) : Number(val) || 0
1561
+ return { key, total }
1562
+ })
1563
+ .filter((m) => m.total > 0)
1564
+ .sort((a, b) => b.total - a.total)
1565
+ .slice(0, 8)
1566
+
1567
+ lines.push('')
1568
+ lines.push(` ${themeColors.textBold('TOP MODELS TODAY')}`)
1569
+ if (sortedModels.length === 0) {
1570
+ lines.push(themeColors.dim(' No usage tracked yet today.'))
1571
+ } else {
1572
+ const maxTotal = sortedModels[0]?.total || 1
1573
+ for (const m of sortedModels) {
1574
+ const barLen = Math.max(2, Math.round((m.total / maxTotal) * 28))
1575
+ const bar = themeColors.success('█'.repeat(barLen)) + themeColors.dim('░'.repeat(28 - barLen))
1576
+ const pct = todayTotal > 0 ? Math.round((m.total / todayTotal) * 100) : 0
1577
+ lines.push(` ${bar} ${themeColors.textBold(formatTokenTotalCompact(m.total))} tok ${themeColors.dim(`${pct}% ${m.key}`)}`)
1578
+ }
1579
+ }
1580
+
1581
+ lines.push('')
1582
+ lines.push(` ${themeColors.textBold('LAST 7 DAYS')}`)
1583
+ const dayLabels = []
1584
+ const dayTotals = []
1585
+ for (let i = 6; i >= 0; i--) {
1586
+ const d = new Date()
1587
+ d.setDate(d.getDate() - i)
1588
+ const key = d.toISOString().slice(0, 10)
1589
+ const dayData = dailyData[key]
1590
+ const total = dayData?.total_tokens || 0
1591
+ dayLabels.push(d.toLocaleDateString('en-US', { weekday: 'short' }))
1592
+ dayTotals.push(total)
1593
+ }
1594
+ const maxDay = Math.max(...dayTotals, 1)
1595
+ lines.push(` ${dayLabels.map((l, i) => themeColors.dim(padEndDisplay(l, 6))).join(' ')}`)
1596
+ const barHeights = [14, 10, 7, 4]
1597
+ for (const bh of barHeights) {
1598
+ const row = dayTotals.map((t) => {
1599
+ const filled = Math.round((t / maxDay) * bh)
1600
+ const bar = themeColors.info('█'.repeat(filled)) + themeColors.dim('░'.repeat(bh - filled))
1601
+ return padEndDisplay(bar, 6)
1602
+ })
1603
+ lines.push(` ${row.join(' ')}`)
1604
+ }
1605
+ const totalRow = dayTotals.map((t) => padEndDisplay(themeColors.textBold(formatTokenTotalCompact(t)), 6))
1606
+ lines.push(` ${totalRow.join(' ')}`)
1607
+
1608
+ lines.push('')
1609
+ lines.push(themeColors.dim(' Esc Back to main table • Shift+R Router Dashboard'))
1610
+
1611
+ const { visible, offset } = sliceOverlayLines(lines, state.tokenUsageScrollOffset, state.terminalRows)
1612
+ state.tokenUsageScrollOffset = offset
1613
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1614
+ return tintedLines.map((l) => l + EL).join('\n')
1615
+ }
1616
+
1617
+ // ─── Router Onboarding overlay renderer ─────────────────────────────────────
1618
+ // 📖 renderRouterOnboarding: shown on first launch (no config.router) or
1619
+ // 📖 first launch after upgrade (existing config but router.onboardingSeen !== true).
1620
+ // 📖 Two options: Enable (Y) or Not now (N). Phase 6 — Smart Model Router.
1621
+ function renderRouterOnboarding() {
1622
+ const EL = '\x1b[K'
1623
+ const lines = []
1624
+ const cursorLineByRow = {}
1625
+
1626
+ lines.push('')
1627
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
1628
+ lines.push(` ${themeColors.textBold('🔀 Smart Router Available!')}`)
1629
+ lines.push('')
1630
+ lines.push(themeColors.dim(' FCM can run a background daemon that automatically'))
1631
+ lines.push(themeColors.dim(' routes your requests to the fastest healthy model —'))
1632
+ lines.push(themeColors.dim(' with zero manual intervention after initial setup.'))
1633
+ lines.push('')
1634
+
1635
+ const options = [
1636
+ { label: 'Yes, enable the router', hint: 'Recommended — creates default set and starts daemon', key: 'Y' },
1637
+ { label: 'Not now', hint: 'You can enable it later from the TUI', key: 'N' },
1638
+ ]
1639
+
1640
+ if (state.routerOnboardingPhase === 'loading') {
1641
+ lines.push(themeColors.info(' Enabling router, please wait...'))
1642
+ } else if (state.routerOnboardingPhase === 'success') {
1643
+ lines.push(themeColors.success(' ✅ Router enabled! Dashboard opening...'))
1644
+ lines.push(themeColors.dim(' Shift+R to reopen the dashboard anytime'))
1645
+ } else if (state.routerOnboardingPhase === 'error') {
1646
+ lines.push(themeColors.error(` ❌ ${state.routerOnboardingError || 'Failed to enable router'}`))
1647
+ lines.push(themeColors.dim(' Press Esc or Enter to continue to the main table'))
1648
+ } else {
1649
+ for (let i = 0; i < options.length; i++) {
1650
+ const opt = options[i]
1651
+ const isCursor = i === state.routerOnboardingCursor
1652
+ const keyLabel = themeColors.hotkey(` ${opt.key}]`)
1653
+ const row = `${bullet(isCursor)}${keyLabel} ${isCursor ? themeColors.textBold(opt.label) : themeColors.text(opt.label)}`
1654
+ cursorLineByRow[i] = lines.length
1655
+ lines.push(isCursor ? themeColors.bgCursorSettingsList(row) : row)
1656
+ lines.push(themeColors.dim(` ${opt.hint}`))
1657
+ lines.push('')
1658
+ }
1659
+ lines.push(themeColors.dim(' ↑↓ Navigate • Enter Select • Esc Skip for now'))
1660
+ }
1661
+
1662
+ const targetLine = cursorLineByRow[state.routerOnboardingCursor] ?? 0
1663
+ state.routerOnboardingScrollOffset = keepOverlayTargetVisible(state.routerOnboardingScrollOffset, targetLine, lines.length, state.terminalRows)
1664
+ const { visible, offset } = sliceOverlayLines(lines, state.routerOnboardingScrollOffset, state.terminalRows)
1665
+ state.routerOnboardingScrollOffset = offset
1666
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
1667
+ return tintedLines.map((l) => l + EL).join('\n')
1668
+ }
1669
+
1670
+ // ─── Router upgrade banner (inline in main table, not an overlay) ─────────────
1671
+ // 📖 renderRouterUpgradeBanner: non-blocking notification at top of the table
1672
+ // 📖 shown once to existing users who haven't seen router yet. Auto-dismisses after 10s.
1673
+ function renderRouterUpgradeBanner() {
1674
+ const EL = '\x1b[K'
1675
+ const now = Date.now()
1676
+ const BANNER_TTL_MS = 10_000
1677
+ // Dismissed or already seen in this session?
1678
+ if (state.routerUpgradeBannerDismissedAt > 0) return ''
1679
+ if (state.routerUpgradeBannerShownAt === 0) state.routerUpgradeBannerShownAt = now
1680
+ if (now - state.routerUpgradeBannerShownAt > BANNER_TTL_MS) {
1681
+ state.routerUpgradeBannerDismissedAt = now
1682
+ return ''
1683
+ }
1684
+ const remaining = Math.ceil((BANNER_TTL_MS - (now - state.routerUpgradeBannerShownAt)) / 1000)
1685
+ const msg = ` ${themeColors.accentBold('🆕')} ${themeColors.textBold('Smart Router is now available!')} ${themeColors.dim('Press')} ${themeColors.hotkey('Shift+R')} ${themeColors.dim('to set it up.')} ${themeColors.dim(`(dismisses in ${remaining}s)`)}`
1686
+ const pad = state.terminalCols > displayWidth(msg) ? ' '.repeat(Math.max(0, state.terminalCols - displayWidth(msg))) : ''
1687
+ return themeColors.warningBold(msg + pad)
1688
+ }
1689
+
1493
1690
  return {
1494
1691
  renderSettings,
1495
1692
  renderInstallEndpoints,
@@ -1500,9 +1697,13 @@ export function createOverlayRenderers(state, deps) {
1500
1697
  renderFeedback,
1501
1698
  renderChangelog,
1502
1699
  renderInstalledModels,
1700
+ renderRouterDashboard,
1503
1701
  renderIncompatibleFallback,
1702
+ renderTokenUsage,
1703
+ renderRouterOnboarding,
1704
+ renderRouterUpgradeBanner,
1504
1705
  startRecommendAnalysis,
1505
1706
  stopRecommendAnalysis,
1506
- overlayLayout, // 📖 Mouse support: exposes cursor-to-line maps for click handling
1707
+ overlayLayout,
1507
1708
  }
1508
1709
  }
@@ -45,22 +45,17 @@ export const ENV_VAR_NAMES = {
45
45
  cerebras: 'CEREBRAS_API_KEY',
46
46
  sambanova: 'SAMBANOVA_API_KEY',
47
47
  openrouter: 'OPENROUTER_API_KEY',
48
- huggingface:'HUGGINGFACE_API_KEY',
49
- replicate: 'REPLICATE_API_TOKEN',
50
- deepinfra: 'DEEPINFRA_API_KEY',
51
- fireworks: 'FIREWORKS_API_KEY',
52
- codestral: 'CODESTRAL_API_KEY',
53
- hyperbolic: 'HYPERBOLIC_API_KEY',
48
+ 'github-models': 'GITHUB_TOKEN',
49
+ mistral: 'MISTRAL_API_KEY',
50
+ codestral: 'MISTRAL_API_KEY',
54
51
  scaleway: 'SCALEWAY_API_KEY',
55
52
  googleai: 'GOOGLE_API_KEY',
56
- siliconflow:'SILICONFLOW_API_KEY',
57
- together: 'TOGETHER_API_KEY',
58
53
  cloudflare: 'CLOUDFLARE_API_TOKEN',
59
- perplexity: 'PERPLEXITY_API_KEY',
60
54
  zai: 'ZAI_API_KEY',
61
55
  gemini: 'GEMINI_API_KEY',
62
- chutes: 'CHUTES_API_KEY',
63
56
  ovhcloud: 'OVH_AI_ENDPOINTS_ACCESS_TOKEN',
57
+ qwen: 'DASHSCOPE_API_KEY',
58
+ 'opencode-zen': 'OPENCODE_ZEN_API_KEY',
64
59
  }
65
60
 
66
61
  // 📖 OPENCODE_MODEL_MAP: sparse table of model IDs that differ between sources.js and OpenCode's
@@ -103,7 +98,7 @@ export const PROVIDER_METADATA = {
103
98
  color: chalk.rgb(255, 224, 178),
104
99
  signupUrl: 'https://cloud.sambanova.ai/apis',
105
100
  signupHint: 'SambaCloud portal → Create API key',
106
- rateLimits: 'Dev tier generous quota',
101
+ rateLimits: 'Small developer quota; useful for light coding and smoke tests',
107
102
  },
108
103
  openrouter: {
109
104
  label: 'OpenRouter',
@@ -113,6 +108,20 @@ export const PROVIDER_METADATA = {
113
108
  rateLimits: 'Free on :free: 50/day <$10, 1000/day ≥$10 (20 req/min)',
114
109
  detailedLimits: 'No credits (or <$10) → 50 requests/day (20 req/min)\n≥ $10 in credits → 1000 requests/day (20 req/min)\n• Free models (:free) never consume credits\n• Failed requests count toward quota\n• Quota resets daily at midnight UTC\n• Free-tier models may be rate-limited during peak hours',
115
110
  },
111
+ 'github-models': {
112
+ label: 'GitHub Models',
113
+ color: chalk.rgb(183, 201, 255),
114
+ signupUrl: 'https://models.github.ai',
115
+ signupHint: 'Use a GitHub token with Models access (GITHUB_TOKEN works in GitHub contexts)',
116
+ rateLimits: 'Quota depends on GitHub/Copilot tier; no separate provider billing',
117
+ },
118
+ mistral: {
119
+ label: 'Mistral La Plateforme',
120
+ color: chalk.rgb(255, 196, 120),
121
+ signupUrl: 'https://console.mistral.ai/api-keys',
122
+ signupHint: 'La Plateforme → API keys (MISTRAL_API_KEY)',
123
+ rateLimits: 'Experiment plan: free evaluation tier with limited RPS/TPM/monthly tokens',
124
+ },
116
125
  huggingface: {
117
126
  label: 'Hugging Face Inference',
118
127
  color: chalk.rgb(255, 245, 157),
@@ -146,9 +155,9 @@ export const PROVIDER_METADATA = {
146
155
  codestral: {
147
156
  label: 'Mistral Codestral',
148
157
  color: chalk.rgb(248, 187, 208),
149
- signupUrl: 'https://codestral.mistral.ai',
150
- signupHint: 'API KeysCreate',
151
- rateLimits: '30 req/min, 2000/day',
158
+ signupUrl: 'https://console.mistral.ai/api-keys',
159
+ signupHint: 'La PlateformeAPI keys (MISTRAL_API_KEY; CODESTRAL_API_KEY also works)',
160
+ rateLimits: 'Codestral free access: 30 req/min, 2000/day',
152
161
  },
153
162
  hyperbolic: {
154
163
  label: 'Hyperbolic',
@@ -169,7 +178,7 @@ export const PROVIDER_METADATA = {
169
178
  color: chalk.rgb(187, 222, 251),
170
179
  signupUrl: 'https://aistudio.google.com/apikey',
171
180
  signupHint: 'Get API key',
172
- rateLimits: '14.4K req/day, 30/min',
181
+ rateLimits: 'Gemini free quotas vary by model and region',
173
182
  },
174
183
  siliconflow: {
175
184
  label: 'SiliconFlow',
@@ -211,7 +220,7 @@ export const PROVIDER_METADATA = {
211
220
  color: chalk.rgb(174, 213, 255),
212
221
  signupUrl: 'https://z.ai',
213
222
  signupHint: 'Sign up and generate an API key',
214
- rateLimits: 'Free tier (generous quota)',
223
+ rateLimits: 'Free tier: Flash models only in this catalog',
215
224
  },
216
225
  iflow: {
217
226
  label: 'iFlow',
@@ -240,7 +249,7 @@ export const PROVIDER_METADATA = {
240
249
  color: chalk.rgb(66, 165, 245), // blue
241
250
  signupUrl: 'https://github.com/google-gemini/gemini-cli',
242
251
  signupHint: 'Install: npm install -g @google/gemini-cli',
243
- rateLimits: 'Free tier: 1,000 req/day (personal Google account, no credit card)',
252
+ rateLimits: 'Free tier: 1,000 req/day with personal Google account',
244
253
  cliOnly: true,
245
254
  },
246
255
  'opencode-zen': {
@@ -29,7 +29,7 @@
29
29
  * @property {'header'|'endpoint'|'unknown'} telemetryType
30
30
  * @property {boolean} [supportsEndpoint]
31
31
  * @property {'percent'|'ok'} usageDisplay
32
- * @property {'rolling'|'daily'|'unknown'|'none'} resetCadence
32
+ * @property {'rolling'|'daily'|'monthly'|'unknown'|'none'} resetCadence
33
33
  */
34
34
 
35
35
  /** @type {Record<string, ProviderCapability>} */
@@ -39,26 +39,22 @@ export const PROVIDER_CAPABILITIES = {
39
39
  groq: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
40
40
  cerebras: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
41
41
  sambanova: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
42
- deepinfra: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
43
- fireworks: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
44
- together: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
45
- hyperbolic: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
42
+ 'github-models': { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
43
+ mistral: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'monthly' },
46
44
  scaleway: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
47
45
  googleai: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
48
46
  codestral: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'daily' },
49
- perplexity: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
50
47
  qwen: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
48
+ ovhcloud: { telemetryType: 'header', supportsEndpoint: false, usageDisplay: 'percent', resetCadence: 'unknown' },
51
49
 
52
50
  // Providers that have a dedicated usage/credits endpoint
53
51
  openrouter: { telemetryType: 'endpoint', supportsEndpoint: true, usageDisplay: 'percent', resetCadence: 'unknown' },
54
- siliconflow: { telemetryType: 'endpoint', supportsEndpoint: true, usageDisplay: 'ok', resetCadence: 'unknown' },
55
52
 
56
53
  // Providers with no reliable quota signal
57
- huggingface: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
58
- replicate: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
59
54
  cloudflare: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
60
55
  zai: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
61
- iflow: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'none' },
56
+ gemini: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
57
+ 'opencode-zen': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
62
58
  }
63
59
 
64
60
  /** Fallback for unrecognized providers */
@@ -54,6 +54,7 @@ import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displ
54
54
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from './tool-metadata.js'
55
55
  import { getColumnSpacing } from './ui-config.js'
56
56
  import { detectPackageManager, getManualInstallCmd } from './updater.js'
57
+ import { formatTokenTotalCompact } from './token-usage-reader.js'
57
58
 
58
59
  const require = createRequire(import.meta.url)
59
60
  const { version: LOCAL_VERSION } = require('../package.json')
@@ -109,7 +110,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
109
110
  })
110
111
 
111
112
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
112
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, footerHidden = false, verdictFilterMode = 0, healthFilterMode = 0) {
113
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, footerHidden = false, verdictFilterMode = 0, healthFilterMode = 0, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
113
114
  // 📖 Filter out hidden models for display
114
115
  const visibleResults = results.filter(r => !r.hidden)
115
116
 
@@ -537,9 +538,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
537
538
  ? providerName.slice(0, 4) + '…'
538
539
  : providerName
539
540
  const source = themeColors.provider(r.providerKey, providerDisplay.padEnd(wSource))
540
- // 📖 Favorites: always reserve 2 display columns at the start of Model column.
541
- // 📖 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
542
- const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
541
+ const CIRCLED = ['①','②','③','④','⑤','⑥','⑦','⑧','⑨','⑩','⑪','⑫','⑬','⑭','⑮','⑯','⑰','⑱','⑲','⑳']
542
+ let favoritePrefix = ' '
543
+ if (r.isRecommended) {
544
+ favoritePrefix = '🎯'
545
+ } else if (r.isFavorite && r.favoriteRank < CIRCLED.length) {
546
+ favoritePrefix = CIRCLED[r.favoriteRank]
547
+ } else if (r.isFavorite) {
548
+ favoritePrefix = '⭐'
549
+ }
543
550
  const prefixDisplayWidth = 2
544
551
  const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
545
552
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
@@ -865,6 +872,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
865
872
  { text: ' • ', key: null },
866
873
  { text: 'Q Smart Recommend', key: 'q' },
867
874
  { text: ' • ', key: null },
875
+ { text: 'Shift+R Router', key: 'shift+r' },
876
+ { text: ' • ', key: null },
868
877
  { text: 'G Theme', key: 'g' },
869
878
  { text: ' • ', key: null },
870
879
  { text: 'I Feedback, bugs & requests', key: 'i' },
@@ -884,6 +893,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
884
893
  lines.push(
885
894
  ' ' + paletteLabel + themeColors.dim(` • `) +
886
895
  hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
896
+ hotkey('Shift+R', ' Router') + themeColors.dim(` • `) +
887
897
  hotkey('G', ' Theme') + themeColors.dim(` • `) +
888
898
  hotkey('I', ' Feedback, bugs & requests')
889
899
  )
@@ -968,6 +978,27 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
968
978
  (releaseLabel ? themeColors.dim(' • ') + releaseLabel : '')
969
979
  )
970
980
 
981
+ // 📖 Router token stats + daemon status in the footer (shown when router is enabled)
982
+ if (routerFooterRunning) {
983
+ const todayStr = formatTokenTotalCompact(routerFooterTodayTokens)
984
+ const allTimeStr = formatTokenTotalCompact(routerFooterAllTimeTokens)
985
+ const reqStr = String(routerFooterRequests)
986
+ const setLabel = routerFooterActiveSet ? themeColors.info(routerFooterActiveSet) : themeColors.dim('?')
987
+ lines.push(
988
+ ' ' + themeColors.success('●') + ' ' +
989
+ themeColors.dim('Router:') + ' ' + setLabel +
990
+ themeColors.dim(' • Today:') + ' ' + themeColors.textBold(todayStr + ' tok') +
991
+ themeColors.dim(' • All-time:') + ' ' + themeColors.textBold(allTimeStr + ' tok') +
992
+ themeColors.dim(' • ' + reqStr + ' req')
993
+ )
994
+ } else {
995
+ lines.push(
996
+ ' ' + themeColors.error('○') + ' ' +
997
+ themeColors.dim('Router:') + ' ' + themeColors.dim('daemon not running') +
998
+ themeColors.dim(' • Shift+R Dashboard')
999
+ )
1000
+ }
1001
+
971
1002
  // 📖 Discord link at the very bottom of the TUI
972
1003
  lines.push(
973
1004
  ' 💬 ' +
@@ -979,6 +1010,8 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
979
1010
  // 📖 Collapsed footer: single line with toggle hint
980
1011
  lines.push(
981
1012
  ' ' + themeColors.hotkey('Ctrl+O') + themeColors.dim(' Toggle Footer') +
1013
+ themeColors.dim(' • ') +
1014
+ themeColors.hotkey('Shift+R') + themeColors.dim(' Router') +
982
1015
  themeColors.dim(' • Ctrl+C Exit')
983
1016
  )
984
1017
  }