free-coding-models 0.5.14 → 0.5.15

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.
@@ -53,10 +53,68 @@
53
53
  is exact-string equality). Moved them above the switch so they
54
54
  actually run. This is why the original reorder/add/remove proxies
55
55
  returned 404 in the v0.5.11 web build.
56
+ - **Auto-heal the active router set on startup.** When the daemon starts,
57
+ it waits for the first probe burst to populate health data, then runs
58
+ `autoHealActiveSet()` to swap any broken model in the active set
59
+ (`AUTH_ERROR` or `STALE` state) for a working alternative. The picker
60
+ prefers the same provider first, then falls through to cross-provider
61
+ candidates, and skips providers whose every probe has been broken.
62
+ Three passes run at 8s, 24s, and 40s after startup so a freshly-added
63
+ replacement that turns out to be broken gets replaced again. The result:
64
+ a new user with a half-broken key set lands on a usable default set
65
+ by the time the Web Dashboard renders, and the Playground's `fcm`
66
+ virtual model starts returning successful responses immediately.
67
+ - **`router.userCustomized` + `router.autoHeal` config flags.** The first
68
+ manual edit to the active set (add / remove / reorder / sync /
69
+ activate / rename) flips `userCustomized` to `true` and `autoHeal`
70
+ to `false` so the user's manual choices are never undone on the next
71
+ daemon start. New users get `userCustomized: false` and `autoHeal:
72
+ true` by default, which is what powers the M6 "default to working
73
+ models" promise.
74
+ - **`/api/router/status` exposes `autoHeal`, `userCustomized`, and
75
+ `brokenModelCount`.** The Web Router Dashboard reads these to surface
76
+ an amber banner when broken models remain in the active set, with a
77
+ one-click "Fix now" button that re-runs the same `sync-set` probe
78
+ pipeline the CLI uses.
79
+ - **Amber "models not responding" banner in the Web Router Dashboard.**
80
+ Shown when the daemon reports `brokenModelCount > 0`, with a
81
+ dismissable X so the user can ignore it after acknowledging the
82
+ problem. The "Fix now" button re-runs the probe-based heal and reloads
83
+ the dashboard state.
84
+ - **Unusable row fade (TUI + Web + Desktop).** Rows whose health is
85
+ `NO KEY` (`noauth`) or `AUTH FAIL` (`auth_error`) are now rendered at
86
+ 80% opacity (20% less opaque) on every user-facing surface. The user
87
+ can scan the table and instantly see which models they cannot
88
+ actually use, even when the cursor is parked on a different model.
89
+ - **TUI:** the new `fadedRow()` helper in `src/tui/render-helpers.js`
90
+ multiplies every 24-bit RGB channel inside an ANSI-colored string by
91
+ `0.8`, so the whole line reads as uniformly faded. This works on
92
+ every terminal that supports truecolor and does not rely on the
93
+ SGR 2 "faint" code, which is ignored by some terminals. The fade
94
+ composes cleanly with the cursor highlight, the dark-red
95
+ `incompatible` background, the green `recommended` background, and
96
+ the gold `favorite` background, so no existing visual cue is lost
97
+ — the "unusable" signal just rides on top of them.
98
+ - **Web / Desktop (Tauri):** the `ModelTable` adds an
99
+ `.unusable { opacity: 0.8 }` CSS class to rows whose `m.status` is
100
+ `noauth` or `auth_error`. The class is held steady on hover so the
101
+ "you cannot use this" signal never disappears while the user is
102
+ inspecting the row.
103
+ - **`fadedRow(input, factor = 0.8)` helper.** Pure function exported
104
+ from `src/tui/render-helpers.js`, documented and unit-tested in
105
+ isolation. Identity fast-path for `factor >= 1`, channels clamped to
106
+ 0–255, bold/dim/reset SGR codes pass through unchanged. Reusable for
107
+ any future "fade a whole line" need (e.g. stale rows, soft-disabled
108
+ providers).
56
109
  - **6 new unit tests** for the granular set-management endpoints
57
110
  (add, duplicate, remove, reorder, reorder-with-missing-key,
58
- catalog) and **2 new tests** for `buildDefaultRouterSet`'s probe
59
- path (probe-preference + sync fallback). All 495 tests pass.
111
+ catalog), **2 new tests** for `buildDefaultRouterSet`'s probe path
112
+ (probe-preference + sync fallback), **4 new unit tests** for the
113
+ auto-heal path (no-op when user-customized, no-op when auto-heal is
114
+ disabled, no-op when no broken models, and the
115
+ user-edit-flags-customization round-trip), and **12 new tests** for
116
+ the unusable row fade (7 for `fadedRow` + 5 for the renderTable
117
+ integration). **All 515 tests pass.**
60
118
 
61
119
  ### Notes
62
120
  - The TUI's `--sync-set` flag is unchanged — the Web "Sync best" button
@@ -66,3 +124,29 @@
66
124
  automatically picks up the new working-models set, so the
67
125
  Playground will start returning successful responses as soon as the
68
126
  Web user clicks "Sync best" once.
127
+ - The auto-heal is best-effort: if the user has only one working
128
+ provider, the healed set will shrink to that one provider's top
129
+ model. That's still better than the previous behavior of showing
130
+ three models that all 401.
131
+ - The new broken-model banner is intentionally subtle (amber, not red)
132
+ because the auto-heal already does its best to recover. It's there
133
+ for the "user has 0 working keys" edge case so the user can click
134
+ through to "Fix now" / "Sync best" and either get a working set or
135
+ see the toast explaining the situation.
136
+ - The cursor row is still faded if the model is unusable — the user's
137
+ request was "the WHOLE line at 80% opacity" and we honor that
138
+ literally. The cursor highlight (blue background) gets its colors
139
+ multiplied by 0.8 too, so it remains visible but reads as "dimmed",
140
+ consistent with the rest of the line.
141
+
142
+ ### How the auto-heal picker works (M6)
143
+ ```
144
+ For each broken model in the active set:
145
+ 1. Same provider — pick a working model of the same provider
146
+ (skipping models that the circuit breaker already knows are broken).
147
+ 2. Cross-provider — fall through to any working model across all
148
+ providers, sorted by static tier + swe score.
149
+ 3. If neither yields a working model, leave the broken entry in
150
+ place and log a warning (the Web UI surfaces this in the
151
+ "models not responding" banner).
152
+ ```
@@ -1,57 +1,23 @@
1
- # Changelog v0.5.13 - 2026-06-02
1
+ # Changelog v0.5.13 - 2026-06-04
2
2
 
3
3
  ### Added
4
- - **Auto-heal the active router set on startup.** When the daemon starts,
5
- it waits for the first probe burst to populate health data, then runs
6
- `autoHealActiveSet()` to swap any broken model in the active set
7
- (`AUTH_ERROR` or `STALE` state) for a working alternative. The picker
8
- prefers the same provider first, then falls through to cross-provider
9
- candidates, and skips providers whose every probe has been broken.
10
- Three passes run at 8s, 24s, and 40s after startup so a freshly-added
11
- replacement that turns out to be broken gets replaced again. The result:
12
- a new user with a half-broken key set lands on a usable default set
13
- by the time the Web Dashboard renders, and the Playground's `fcm`
14
- virtual model starts returning successful responses immediately.
15
- - **`router.userCustomized` + `router.autoHeal` config flags.** The first
16
- manual edit to the active set (add / remove / reorder / sync /
17
- activate / rename) flips `userCustomized` to `true` and `autoHeal`
18
- to `false` so the user's manual choices are never undone on the next
19
- daemon start. New users get `userCustomized: false` and `autoHeal:
20
- true` by default, which is what powers the M6 "default to working
21
- models" promise.
22
- - **`/api/router/status` exposes `autoHeal`, `userCustomized`, and
23
- `brokenModelCount`.** The Web Router Dashboard reads these to surface
24
- an amber banner when broken models remain in the active set, with a
25
- one-click "Fix now" button that re-runs the same `sync-set` probe
26
- pipeline the CLI uses.
27
- - **Amber "models not responding" banner in the Web Router Dashboard.**
28
- Shown when the daemon reports `brokenModelCount > 0`, with a
29
- dismissable X so the user can ignore it after acknowledging the
30
- problem. The "Fix now" button re-runs the probe-based heal and reloads
31
- the dashboard state.
32
- - **4 new unit tests** for the auto-heal path: no-op when user-customized,
33
- no-op when auto-heal is disabled, no-op when no broken models, and
34
- the user-edit-flags-customization round-trip. **All 515 tests pass.**
4
+ - **Provider dropdown with SVG logos + health indicators** — The native `<select>` for provider filtering has been replaced by a custom dropdown that renders each provider's SVG icon + text wordmark (same `ProviderLogo` component used in the model table) alongside a color-coded health indicator dot:
5
+ - 🟢 Green = API key configured and at least one model is UP
6
+ - 🔴 Red = API key set but models are DOWN or have auth errors
7
+ - Gray = No API key configured
8
+ - Colors adapt for both dark and light themes for readability.
9
+ - Scrollable list with keyboard support (Escape to close, click-outside to dismiss).
10
+ - **Collapsible filter groups** — Tier, Status, Verdict, and Health filter chips are now collapsible dropdown triggers (like the Ping selector). Shows `LABEL: value` as a compact button; click to expand the chip row. Reduces visual clutter while keeping full chip sets on demand.
11
+ - **Playground direct provider routing** — The playground can now chat directly with any "UP" model (not just via the router daemon). Select a specific model → the request goes straight to the provider endpoint. Falls back to daemon for `fcm` auto-router. Clear error messages when no API key is configured for the selected provider.
12
+ - **Playground daemon start/stop controls** — When the router daemon is offline, the Playground now shows a start button instead of a dead panel. One-click to launch the daemon and begin chatting.
13
+ - **"How the FCM Router works" help section** — New section in the Help modal (and TUI help overlay) explaining the smart router, pre-prompt, probe mechanism, circuit breaker states, failover order, auto-heal, and rate limits in plain English.
14
+ - **Router circuit breaker friendly labels** — Circuit state badges in the Router dashboard now show human-readable labels (`Healthy`, `Down`, `Recovering`, `Auth error`) instead of raw internal names (`CLOSED`, `OPEN`, `HALF_OPEN`).
15
+ - **Next-ping countdown with millisecond precision** — The filter bar countdown now shows `3.58s` format instead of rounding to whole seconds, matching TUI parity.
35
16
 
36
- ### Notes
37
- - The auto-heal is best-effort: if the user has only one working
38
- provider, the healed set will shrink to that one provider's top
39
- model. That's still better than the previous behavior of showing
40
- three models that all 401.
41
- - The new broken-model banner is intentionally subtle (amber, not red)
42
- because the auto-heal already does its best to recover. It's there
43
- for the "user has 0 working keys" edge case so the user can click
44
- through to "Fix now" / "Sync best" and either get a working set or
45
- see the toast explaining the situation.
17
+ ### Changed
18
+ - **FilterBar refactor** Replaced the inline `ChipRow` component with a reusable `FilterGroup` component that handles collapsible open/close state, click-outside detection, and keyboard dismiss. The Ping selector was already using this pattern; now all filter groups use it.
19
+ - **Provider data aggregation** The `providers` prop passed to FilterBar now includes `hasKey` and `anyUp` boolean flags computed from the live model data, enabling the health indicator in the provider dropdown.
20
+ - **Provider dropdown replaces native `<select>`** — The `<select id="provider-select">` and its CSS (`.providerSelect`) are removed from FilterBar. The new `ProviderDropdown` component provides SVG logo rendering, health dots, and a scrollable menu that's consistent with the rest of the filter bar UX.
46
21
 
47
- ### How the picker works (M6)
48
- ```
49
- For each broken model in the active set:
50
- 1. Same provider — pick a working model of the same provider
51
- (skipping models that the circuit breaker already knows are broken).
52
- 2. Cross-provider — fall through to any working model across all
53
- providers, sorted by static tier + swe score.
54
- 3. If neither yields a working model, leave the broken entry in
55
- place and log a warning (the Web UI surfaces this in the
56
- "models not responding" banner).
57
- ```
22
+ ### Fixed
23
+ - **Playground no longer dead when router offline** — Previously the Playground showed a static "Router offline" message with no action. Now it offers a start button and can also chat directly with providers that have working API keys.
@@ -0,0 +1,17 @@
1
+ # Changelog v0.5.15 - 2026-06-04
2
+
3
+ ### Added
4
+ - **G Theme** footer hotkey — `G` now appears in the bottom shortcut bar with a prominent info-bold style so users immediately see they can cycle `auto → dark → light` themes from the keyboard
5
+ - **Forced background colours per theme** — light mode paints a pure white background (`#FFFFFF`) on every line, and dark mode paints a deep dark blue-black background (`#070D18`), completely ignoring the terminal's native colour scheme. Light mode now renders correctly even inside a macOS Dark-mode terminal, and vice-versa
6
+ - **Full dark/light palette coverage** — every hardcoded foreground and background colour in the TUI now has proper dark and light variants:
7
+ - Context window size colours (gold/green/teal/cyan) now use muted, readable variants in light mode
8
+ - Column header flash & sort-active backgrounds use auto-contrast text (`getReadableTextRgb`) instead of hardcoded white
9
+ - Auth-fail row backgrounds are light red in light mode instead of dark red
10
+ - Update banner, speed test badge, benchmark badge, Ctrl+P palette label, Twitter link, and release date text all have dedicated light-mode palette entries
11
+ - Header logo bar keeps its black background in both modes (intentional contrast)
12
+ - Initial alt-screen fill with the active theme background colour to prevent a flash of the terminal's native bg before the first TUI frame renders
13
+
14
+ ### Changed
15
+ - `THEME_BG_RGB` export in `theme.js` provides a dynamic bg fill color for the active theme
16
+ - `renderTable` now injects `\x1b[48;2;R;G;Bm` before every `\x1b[K` (erase-to-EOL) and `\x1b[J` (erase-below) so space clearing respects the theme's background
17
+ - 19 hardcoded `chalk.rgb()` / `chalk.bgRgb()` calls replaced with `currentPalette()` lookups
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
@@ -67,6 +67,7 @@
67
67
  "@tabler/icons-react": "^3.44.0",
68
68
  "@tanstack/react-table": "^8.21.3",
69
69
  "chalk": "^5.6.2",
70
+ "kandown": "^0.4.0",
70
71
  "socket.io": "^4.8.3",
71
72
  "socket.io-client": "^4.8.3"
72
73
  },
@@ -76,8 +77,8 @@
76
77
  },
77
78
  "devDependencies": {
78
79
  "@vitejs/plugin-react": "^6.0.2",
79
- "react": "^19.2.6",
80
- "react-dom": "^19.2.6",
80
+ "react": "^19.2.7",
81
+ "react-dom": "^19.2.7",
81
82
  "vite": "^8.0.16",
82
83
  "vite-plus": "^0.1.23"
83
84
  }
package/src/tui/app.js CHANGED
@@ -130,7 +130,7 @@ import { getConfiguredInstallableProviders, installProviderEndpoints, refreshIns
130
130
  import { loadCache, saveCache, clearCache, getCacheAge } from '../core/cache.js'
131
131
  import { checkConfigSecurity } from '../core/security.js'
132
132
  import { buildCliHelpText } from './cli-help.js'
133
- import { detectActiveTheme } from './theme.js'
133
+ import { detectActiveTheme, THEME_BG_RGB, getTheme, patchThemeBg } from './theme.js'
134
134
 
135
135
  // 📖 mergedModels: cross-provider grouped model list (one entry per label, N providers each)
136
136
  // 📖 mergedModelByLabel: fast lookup map from display label → merged model entry
@@ -510,6 +510,16 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
510
510
 
511
511
  // 📖 Enter alternate screen — animation runs here, zero scrollback pollution
512
512
  process.stdout.write(ALT_ENTER)
513
+
514
+ // 📖 Force the entire alt-screen background to match the active theme so
515
+ // 📖 light mode is pure-white and dark mode is deep-dark, regardless of the
516
+ // 📖 terminal's native colour scheme. We paint every row + erase below.
517
+ const initBg = THEME_BG_RGB[getTheme()] ?? THEME_BG_RGB.dark
518
+ const bgFill = `\x1b[48;2;${initBg[0]};${initBg[1]};${initBg[2]}m`
519
+ const row = bgFill + ' '.repeat(state.terminalCols || 80) + '\x1b[K'
520
+ const initScreen = Array.from({ length: state.terminalRows || 24 }, () => row).join('\n')
521
+ process.stdout.write('\x1b[H' + initScreen + bgFill + '\x1b[J' + '\x1b[H')
522
+
513
523
  if (process.stdout.isTTY) {
514
524
  process.stdout.flush && process.stdout.flush()
515
525
  }
@@ -857,7 +867,9 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
857
867
  : state.changelogOpen
858
868
  ? overlays.renderChangelog()
859
869
  : tableContent
860
- process.stdout.write(ALT_HOME + content)
870
+ // 📖 Strip stale bg resets emitted by chalk across ALL renderers (table + overlays).
871
+ const patched = patchThemeBg(content)
872
+ process.stdout.write(ALT_HOME + patched)
861
873
  if (process.stdout.isTTY) {
862
874
  process.stdout.flush && process.stdout.flush()
863
875
  }
@@ -877,7 +889,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
877
889
  benchmarkResults: state.benchmarkResults,
878
890
  })
879
891
 
880
- process.stdout.write(ALT_HOME + renderTable({
892
+ process.stdout.write(ALT_HOME + patchThemeBg(renderTable({
881
893
  results: state.results,
882
894
  pendingPings: state.pendingPings,
883
895
  frame: state.frame,
@@ -910,7 +922,7 @@ export async function runApp(cliArgs, config, startupOptions = {}) {
910
922
  bestModeOnly: state.bestModeOnly,
911
923
  benchmarkResults: state.benchmarkResults,
912
924
  benchmarkRunning: state.benchmarkRunning,
913
- }))
925
+ })))
914
926
  if (process.stdout.isTTY) {
915
927
  process.stdout.flush && process.stdout.flush()
916
928
  }
@@ -60,6 +60,86 @@ const EXAMPLES = [
60
60
  "free-coding-models --json | jq '.[0]'",
61
61
  ]
62
62
 
63
+ /**
64
+ * 📖 buildHowTheRouterWorks — a single-source explanation of the router
65
+ * 📖 internals (circuit breaker, probe mechanism, pre-prompt) that the
66
+ * 📖 Web Help modal and the TUI in-app help overlay both render. Keeping
67
+ * 📖 it here prevents the two surfaces from drifting apart.
68
+ */
69
+ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {}) {
70
+ const lines = []
71
+ const header = (text) => `${indent}${paint(chalk, chalk?.bold, text)}`
72
+ const body = (text) => `${indent}${paint(chalk, chalk?.dim, text)}`
73
+ const bullet = (text) => `${indent} • ${text}`
74
+
75
+ lines.push(header('How the FCM Router Works'))
76
+ lines.push('')
77
+
78
+ lines.push(header('1. The smart router daemon'))
79
+ lines.push(body('Point any OpenAI-compatible client at http://localhost:19280/v1'))
80
+ lines.push(body('with model: "fcm". The daemon picks the healthiest model in'))
81
+ lines.push(body('your active set and forwards the request — with automatic'))
82
+ lines.push(body('failover if the first model 429s or 5xxs.'))
83
+ lines.push('')
84
+
85
+ lines.push(header('2. The pre-prompt (system message)'))
86
+ lines.push(body('A first-class system message is injected on every proxied'))
87
+ lines.push(body('request. The default text introduces the assistant as the FCM'))
88
+ lines.push(body('routing agent and points the user to the dashboard URL.'))
89
+ lines.push(body('You can edit it from Settings (Settings → Pre-prompt).'))
90
+ lines.push('')
91
+
92
+ lines.push(header('3. The probe mechanism (every 10s/30s/120s)'))
93
+ lines.push(body('The daemon sends a 1-token chat-completion ping to every model'))
94
+ lines.push(body('in the active set. The probe measures latency + status code, not'))
95
+ lines.push(body('just URL reachability — so a wrong API key is caught and the'))
96
+ lines.push(body('circuit-breaker is opened.'))
97
+ lines.push(bullet('eco: probe every 120s (saves quota)'))
98
+ lines.push(bullet('balanced: probe every 30s (default)'))
99
+ lines.push(bullet('aggressive: probe every 10s (uses more quota)'))
100
+ lines.push('')
101
+
102
+ lines.push(header('4. The circuit breaker (per-model state)'))
103
+ lines.push(body('Each model has a tiny disjoncteur that flips between 3 states.'))
104
+ lines.push(body('The raw jargon is hidden in the UI — here is what the colors mean:'))
105
+ lines.push(bullet('Healthy (green) — last probe returned 2xx, route here freely'))
106
+ lines.push(bullet('Down (red) — last 3 probes failed, skip until cooldown'))
107
+ lines.push(bullet('Recovering (yellow) — cooldown expired, retrying with 1 request'))
108
+ lines.push(bullet('Auth error (orange) — 401/403, your API key is wrong for this model'))
109
+ lines.push(bullet('Deprecated (gray) — removed from the catalog, will be replaced'))
110
+ lines.push(body('When a model flips to Auth error, the auto-heal on next start'))
111
+ lines.push(body('replaces it with a working alternative from the same provider first,'))
112
+ lines.push(body('then falls through to any provider.'))
113
+ lines.push('')
114
+
115
+ lines.push(header('5. Failover order'))
116
+ lines.push(body('Models in the active set are tried in priority order. A model'))
117
+ lines.push(body('in Recovering/Down/Auth error is skipped — the request goes to'))
118
+ lines.push(body('the next healthy one. If ALL models fail, you get a 503 with the'))
119
+ lines.push(body('"models_tried" list in the error body — useful for debugging.'))
120
+ lines.push('')
121
+
122
+ lines.push(header('6. Auto-heal (default behavior)'))
123
+ lines.push(body('On daemon start, the active set is checked. Any model in Auth'))
124
+ lines.push(body('error or Deprecated is swapped for a working alternative. The'))
125
+ lines.push(body('first time you add/remove/reorder a model, auto-heal switches off'))
126
+ lines.push(body('and your manual choices are preserved.'))
127
+ lines.push('')
128
+
129
+ lines.push(header('7. Rate limits (RPD / RPM / TPM)'))
130
+ lines.push(body('Each provider has its own quota. Common free-tier limits:'))
131
+ lines.push(bullet('Groq on-demand: 14 400 RPD, 30 RPM per model'))
132
+ lines.push(bullet('Mistral La Plateforme: 1 RPS, 1B TPM (experiment plan)'))
133
+ lines.push(bullet('NVIDIA NIM: ~40 RPM (no credit card)'))
134
+ lines.push(bullet('OpenRouter free routes: 50 RPD'))
135
+ lines.push(body('When a provider returns 429, the router fails over. When the'))
136
+ lines.push(body('daily quota is fully exhausted, the model goes Auth error and'))
137
+ lines.push(body('auto-heal swaps it out next start.'))
138
+ lines.push('')
139
+
140
+ return lines
141
+ }
142
+
63
143
  function paint(chalk, formatter, text) {
64
144
  if (!chalk || !formatter) return text
65
145
  return formatter(text)
@@ -103,6 +183,13 @@ export function buildCliHelpLines({ chalk = null, indent = '', title = 'CLI Help
103
183
  for (const example of EXAMPLES) {
104
184
  lines.push(`${indent}${paint(chalk, chalk?.cyan, example)}`)
105
185
  }
186
+ lines.push('')
187
+ lines.push('')
188
+ // 📖 Append the "How the router works" deep-dive so a single `--help`
189
+ // 📖 or in-app Help overlay covers everything the user needs.
190
+ for (const line of buildHowTheRouterWorksLines({ chalk, indent })) {
191
+ lines.push(line)
192
+ }
106
193
 
107
194
  return lines
108
195
  }
@@ -2877,26 +2877,14 @@ export function createKeyHandler(ctx) {
2877
2877
 
2878
2878
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2879
2879
 
2880
- // 📖 Shift+R: Open Router Dashboard AND launch OpenCode with the selected model.
2881
- // 📖 If the dashboard is already open, just bring it to front.
2880
+ // 📖 Shift+R: Open / close the Router Dashboard.
2882
2881
  if (key.name === 'r' && key.shift && !key.ctrl && !key.meta) {
2883
2882
  if (state.routerDashboardOpen) {
2883
+ state.routerDashboardOpen = false
2884
2884
  state.routerDashboardScrollOffset = 0
2885
2885
  return
2886
2886
  }
2887
2887
  openRouterDashboardOverlay(state)
2888
- // 📖 If a model is selected in the main table, launch OpenCode with it after opening dashboard
2889
- const selected = state.visibleSorted?.[state.cursor]
2890
- if (selected && selected.providerKey && selected.modelId) {
2891
- const launchModel = {
2892
- modelId: selected.modelId,
2893
- label: selected.label,
2894
- tier: selected.tier,
2895
- providerKey: selected.providerKey,
2896
- }
2897
- // 📖 Launch asynchronously — don't await, dashboard renders while OpenCode starts
2898
- void startOpenCode(launchModel, state.config)
2899
- }
2900
2888
  return
2901
2889
  }
2902
2890
 
@@ -47,7 +47,7 @@ import {
47
47
  TABLE_FOOTER_LINES,
48
48
  FRAMES
49
49
  } from '../core/constants.js'
50
- import { themeColors, currentPalette, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme } from './theme.js'
50
+ import { themeColors, currentPalette, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme, THEME_BG_RGB } from './theme.js'
51
51
  import { TIER_COLOR } from './tier-colors.js'
52
52
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from '../core/utils.js'
53
53
  import { usagePlaceholderForProvider } from '../core/ping.js'
@@ -207,7 +207,7 @@ export function renderTable({
207
207
  const timeSinceLastPing = Date.now() - lastPingTime
208
208
  const timeUntilNextPing = Math.max(0, pingInterval - timeSinceLastPing)
209
209
  const secondsUntilNext = timeUntilNextPing / 1000
210
- const secondsUntilNextLabel = secondsUntilNext.toFixed(1)
210
+ const secondsUntilNextLabel = secondsUntilNext.toFixed(2)
211
211
 
212
212
  const intervalSec = Math.round(pingInterval / 1000)
213
213
  const pingModeMeta = {
@@ -415,8 +415,14 @@ export function renderTable({
415
415
  benchmarkResults,
416
416
  })
417
417
 
418
+ // 📖 Header logo colours — theme-aware bg/green/white
419
+ const hB = currentPalette().headerLogoBg
420
+ const hG = currentPalette().headerLogoGreen
421
+ const hW = currentPalette().headerLogoWhite
422
+ const hBold = (color, text) => chalk.rgb(...color).bgRgb(...hB).bold(text)
423
+
418
424
  const lines = [
419
- ` ${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold(' > ')}${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold('free')}${chalk.rgb(255, 255, 255).bgRgb(0, 0, 0).bold('-coding-models')}${chalk.rgb(118, 185, 0).bgRgb(0, 0, 0).bold('_ ')} ${themeColors.dim(`v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
425
+ ` ${hBold(hG, ' > ')}${hBold(hG, 'free')}${hBold(hW, '-coding-models')}${hBold(hG, '_ ')} ${themeColors.dim(`v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
420
426
  themeColors.dim('📦 ') + themeColors.accentBold(`${completedPings}/${totalVisible}`) + themeColors.dim(' ') +
421
427
  themeColors.success(`✅ ${up}`) + themeColors.dim(' up ') +
422
428
  themeColors.warning(`⏳ ${timeout}`) + themeColors.dim(' timeout ') +
@@ -459,8 +465,9 @@ export function renderTable({
459
465
  // 📖 This gives satisfying visual feedback that the click was registered.
460
466
  const flashHeader = (plainText, width) => {
461
467
  const padded = plainText.length <= width ? plainText.padEnd(width) : plainText.slice(0, width)
462
- const [r, g, b] = currentPalette().accentStrong
463
- return chalk.bold.rgb(255, 255, 255).bgRgb(r, g, b)(padded)
468
+ const bg = currentPalette().accentStrong
469
+ const fg = getReadableTextRgb(bg)
470
+ return chalk.bold.rgb(...fg).bgRgb(...bg)(padded)
464
471
  }
465
472
 
466
473
  // 📖 Sort-active header: renders the column header with a subtle background color
@@ -474,7 +481,8 @@ export function renderTable({
474
481
  const padded = text.padEnd(width).slice(0, width)
475
482
  // 📖 Subtle dark accent background — visible but not overwhelming.
476
483
  const bg = currentPalette().cursor.defaultBg
477
- return chalk.bold.rgb(255, 255, 255).bgRgb(...bg)(padded)
484
+ const fg = getReadableTextRgb(bg)
485
+ return chalk.bold.rgb(...fg).bgRgb(...bg)(padded)
478
486
  }
479
487
 
480
488
  // 📖 Now colorize each column header.
@@ -496,8 +504,8 @@ export function renderTable({
496
504
  const moodH_c = (() => {
497
505
  // 📖 Tiny verdict indicator column: keep it emoji-only, no arrow, so it stays 2 cells wide.
498
506
  const padded = padEndDisplay(moodLabel, W_MOOD)
499
- if (headerFlashColumn === 'verdict') return chalk.bold.rgb(255, 255, 255).bgRgb(...currentPalette().accentStrong)(padded)
500
- if (sortColumn === 'verdict') return chalk.bold.rgb(255, 255, 255).bgRgb(...currentPalette().cursor.defaultBg)(padded)
507
+ if (headerFlashColumn === 'verdict') return chalk.bold.rgb(...getReadableTextRgb(currentPalette().accentStrong)).bgRgb(...currentPalette().accentStrong)(padded)
508
+ if (sortColumn === 'verdict') return chalk.bold.rgb(...getReadableTextRgb(currentPalette().cursor.defaultBg)).bgRgb(...currentPalette().cursor.defaultBg)(padded)
501
509
  return themeColors.hotkey(padded)
502
510
  })()
503
511
  const rankH_c = headerStyle('rank', rankLabel, W_RANK)
@@ -681,12 +689,12 @@ export function renderTable({
681
689
  : numK <= 64
682
690
  ? themeColors.metricWarn(ctxRaw.padEnd(W_CTX))
683
691
  : numK <= 128
684
- ? chalk.rgb(200, 180, 50).bold(ctxRaw.padEnd(W_CTX))
692
+ ? chalk.rgb(...currentPalette().ctxGold).bold(ctxRaw.padEnd(W_CTX))
685
693
  : numK <= 256
686
- ? chalk.rgb(100, 200, 80).bold(ctxRaw.padEnd(W_CTX))
694
+ ? chalk.rgb(...currentPalette().ctxGreen).bold(ctxRaw.padEnd(W_CTX))
687
695
  : numK <= 400
688
- ? chalk.rgb(0, 255, 200).bold(ctxRaw.padEnd(W_CTX))
689
- : chalk.rgb(0, 255, 255).bold.underline(ctxRaw.padEnd(W_CTX))
696
+ ? chalk.rgb(...currentPalette().ctxTeal).bold(ctxRaw.padEnd(W_CTX))
697
+ : chalk.rgb(...currentPalette().ctxCyan).bold.underline(ctxRaw.padEnd(W_CTX))
690
698
  } else {
691
699
  ctxCell = themeColors.dim(ctxRaw.padEnd(W_CTX))
692
700
  }
@@ -965,7 +973,7 @@ export function renderTable({
965
973
  } else if (isIncompatible) {
966
974
  // 📖 Dark red background for models incompatible with the active tool mode.
967
975
  // 📖 This visually warns the user that selecting this model won't work with their current tool.
968
- renderedRow = chalk.bgRgb(60, 15, 15).rgb(180, 130, 130)(row)
976
+ renderedRow = chalk.bgRgb(...currentPalette().rowDimBg).rgb(...currentPalette().rowDimFg)(row)
969
977
  } else if (r.isRecommended) {
970
978
  // 📖 Medium green background for recommended models (distinguishable from favorites)
971
979
  renderedRow = themeColors.bgModelRecommended(row)
@@ -1030,6 +1038,8 @@ export function renderTable({
1030
1038
  { text: 'I Help', key: 'i' },
1031
1039
  { text: ' • ', key: null },
1032
1040
  { text: 'N Reset', key: 'n' },
1041
+ { text: ' • ', key: null },
1042
+ { text: 'G Theme', key: 'g' },
1033
1043
  ]
1034
1044
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
1035
1045
  let xPos = 1
@@ -1061,7 +1071,9 @@ export function renderTable({
1061
1071
  themeColors.dim(` • `) +
1062
1072
  hotkey('I', ' Help') +
1063
1073
  themeColors.dim(` • `) +
1064
- hotkey('N', ' Reset')
1074
+ hotkey('N', ' Reset') +
1075
+ themeColors.dim(` • `) +
1076
+ themeColors.hotkey('G') + themeColors.infoBold(' Theme')
1065
1077
  )
1066
1078
 
1067
1079
  // 📖 Line 2: command palette + GitHub
@@ -1082,11 +1094,11 @@ export function renderTable({
1082
1094
  }
1083
1095
 
1084
1096
  // 📖 Line 2: command palette (simple color, no background) + GitHub link.
1085
- const paletteLabel = chalk.rgb(57, 255, 20).bold('Ctrl+P Cmd Palette')
1097
+ const paletteLabel = chalk.rgb(...currentPalette().cmdPalette).bold('Ctrl+P Cmd Palette')
1086
1098
  const starLink = '⭐ ' + themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\GitHub\x1b]8;;\x1b\\')
1087
1099
  lines.push(
1088
1100
  ' ' + paletteLabel + themeColors.dim(` • `) + starLink + themeColors.dim(` • `) +
1089
- chalk.rgb(255, 168, 209).bold('\x1b]8;;https://x.com/vavanessadev\x1b\\Follow @vavanessadev on X for updates and support\x1b]8;;\x1b\\')
1101
+ chalk.rgb(...currentPalette().twitterLink).bold('\x1b]8;;https://x.com/vavanessadev\x1b\\Follow @vavanessadev on X for updates and support\x1b]8;;\x1b\\')
1090
1102
  )
1091
1103
 
1092
1104
  if (versionStatus.isOutdated) {
@@ -1097,8 +1109,8 @@ export function renderTable({
1097
1109
  ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
1098
1110
  : updateMsg
1099
1111
  const updateBanner = updateWarningMessage
1100
- ? chalk.bgRed.white.bold(paddedBanner)
1101
- : chalk.bgRgb(57, 255, 20).rgb(0, 0, 0).bold(paddedBanner)
1112
+ ? chalk.bgRgb(...currentPalette().updateBannerErrorBg).rgb(...currentPalette().updateBannerErrorFg).bold(paddedBanner)
1113
+ : chalk.bgRgb(...currentPalette().updateBannerBg).rgb(...currentPalette().updateBannerFg).bold(paddedBanner)
1102
1114
  const updateBannerRow = lines.length + 1
1103
1115
  _lastLayout.updateBannerRow = updateBannerRow
1104
1116
  footerHotkeys.push({ key: 'update-click', row: updateBannerRow, xStart: 1, xEnd: Math.max(terminalCols, displayWidth(updateMsg)) })
@@ -1138,10 +1150,10 @@ export function renderTable({
1138
1150
  }
1139
1151
 
1140
1152
  const releaseLabel = lastReleaseDate
1141
- ? chalk.rgb(255, 182, 193)(`Last release: ${lastReleaseDate}`)
1153
+ ? chalk.rgb(...currentPalette().releaseDate)(`Last release: ${lastReleaseDate}`)
1142
1154
  : ''
1143
- const speedTestLabel = chalk.bgRgb(0, 60, 0).rgb(57, 255, 20).bold(' NEW ⭐️ Ctrl+A 🤖 AI Speed Test ')
1144
- const globalBenchmarkLabel = chalk.bgRgb(180, 0, 255).white.bold(' NEW Ctrl+U : Global AI Speed Test (Uses a lot of requests!) ')
1155
+ const speedTestLabel = chalk.bgRgb(...currentPalette().badgeSpeedTestBg).rgb(...currentPalette().badgeSpeedTestFg).bold(' NEW ⭐️ Ctrl+A 🤖 AI Speed Test ')
1156
+ const globalBenchmarkLabel = chalk.bgRgb(...currentPalette().badgeBenchmarkBg).rgb(...currentPalette().badgeBenchmarkFg).bold(' NEW Ctrl+U : Global AI Speed Test (Uses a lot of requests!) ')
1145
1157
 
1146
1158
  // 📖 Line 3: Speed Test (Ctrl+A) + Global Benchmark (Ctrl+U) + Last release
1147
1159
  if (releaseLabel || speedTestLabel || globalBenchmarkLabel) {
@@ -1165,11 +1177,19 @@ export function renderTable({
1165
1177
  }
1166
1178
  _lastLayout.footerHotkeys = footerHotkeys
1167
1179
 
1168
- // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
1169
- // 📖 frames are cleared. \x1b[J clears stale content below without adding a
1170
- // 📖 newline that could scroll the alternate screen.
1171
- const EL = '\x1b[K'
1172
- const cleared = lines.map(l => l + EL)
1180
+ // 📖 Force the theme's background colour on every line so light/dark mode
1181
+ // 📖 is respected even when the terminal's native theme doesn't match.
1182
+ // 📖
1183
+ // 📖 Each line is prefixed with \x1b[48;2;R;G;Bm so the content always renders
1184
+ // 📖 on the correct background, then \x1b[K fills to end-of-line.
1185
+ // 📖 No \x1b[49m (bg reset) is ever emitted — the theme bg persists across frames.
1186
+ const bgRgb = THEME_BG_RGB[getTheme()] ?? THEME_BG_RGB.dark
1187
+ const BG_SET = `\x1b[48;2;${bgRgb[0]};${bgRgb[1]};${bgRgb[2]}m`
1188
+ // 📖 Each line: set bg → render content → erase to EOL (fills with theme bg)
1189
+ const cleared = lines.map(l => BG_SET + l + '\x1b[K')
1173
1190
  if (cleared.length > 0) cleared[cleared.length - 1] += '\x1b[J'
1191
+ // 📖 Every line is prefixed with the theme bg so content always renders on
1192
+ // 📖 the correct background. \x1b[K fills to end-of-line. The app-level render
1193
+ // 📖 loop applies patchThemeBg() to undo chalk's \x1b[49m resets globally.
1174
1194
  return cleared.join('\n')
1175
1195
  }