free-coding-models 0.5.12 → 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.
@@ -0,0 +1,23 @@
1
+ # Changelog v0.5.13 - 2026-06-04
2
+
3
+ ### Added
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.
16
+
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.
21
+
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.12",
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
  }
package/src/tui/theme.js CHANGED
@@ -71,6 +71,27 @@ const PALETTES = {
71
71
  favoriteBg: [76, 55, 17],
72
72
  favoriteFg: [255, 244, 220],
73
73
  },
74
+ // 📖 Extra semantic tokens for elements that need distinct dark/light variants
75
+ headerLogoBg: [0, 0, 0],
76
+ headerLogoGreen: [118, 185, 0],
77
+ headerLogoWhite: [255, 255, 255],
78
+ cmdPalette: [57, 255, 20],
79
+ twitterLink: [255, 168, 209],
80
+ releaseDate: [255, 182, 193],
81
+ ctxGold: [200, 180, 50],
82
+ ctxGreen: [100, 200, 80],
83
+ ctxTeal: [0, 255, 200],
84
+ ctxCyan: [0, 255, 255],
85
+ rowDimBg: [60, 15, 15],
86
+ rowDimFg: [180, 130, 130],
87
+ badgeSpeedTestBg: [0, 60, 0],
88
+ badgeSpeedTestFg: [57, 255, 20],
89
+ badgeBenchmarkBg: [180, 0, 255],
90
+ badgeBenchmarkFg: [255, 255, 255],
91
+ updateBannerBg: [57, 255, 20],
92
+ updateBannerFg: [0, 0, 0],
93
+ updateBannerErrorBg: [196, 30, 30],
94
+ updateBannerErrorFg: [255, 255, 255],
74
95
  },
75
96
  light: {
76
97
  text: [28, 36, 51],
@@ -118,6 +139,27 @@ const PALETTES = {
118
139
  favoriteBg: [255, 241, 208],
119
140
  favoriteFg: [79, 53, 0],
120
141
  },
142
+ // 📖 Light-mode variants — more muted, higher contrast on white bg
143
+ headerLogoBg: [255, 255, 255],
144
+ headerLogoGreen: [0, 120, 40],
145
+ headerLogoWhite: [20, 30, 45],
146
+ cmdPalette: [0, 130, 35],
147
+ twitterLink: [185, 45, 110],
148
+ releaseDate: [185, 45, 85],
149
+ ctxGold: [140, 120, 0],
150
+ ctxGreen: [35, 120, 25],
151
+ ctxTeal: [0, 135, 110],
152
+ ctxCyan: [0, 125, 125],
153
+ rowDimBg: [255, 230, 230],
154
+ rowDimFg: [185, 55, 55],
155
+ badgeSpeedTestBg: [200, 235, 200],
156
+ badgeSpeedTestFg: [0, 95, 0],
157
+ badgeBenchmarkBg: [230, 205, 250],
158
+ badgeBenchmarkFg: [80, 0, 130],
159
+ updateBannerBg: [30, 180, 30],
160
+ updateBannerFg: [0, 0, 0],
161
+ updateBannerErrorBg: [196, 30, 30],
162
+ updateBannerErrorFg: [255, 255, 255],
121
163
  },
122
164
  }
123
165
 
@@ -287,6 +329,13 @@ function paintBg(bgRgb, text, fgRgb = null, options = {}) {
287
329
  return buildStyle({ bgRgb, fgRgb: fgRgb ?? getReadableTextRgb(bgRgb), ...options })(text)
288
330
  }
289
331
 
332
+ // 📖 Background fill colours — used to force the terminal background to match
333
+ // 📖 the active theme. Light mode → solid white, dark mode → solid dark.
334
+ export const THEME_BG_RGB = {
335
+ dark: [7, 13, 24],
336
+ light: [255, 255, 255],
337
+ }
338
+
290
339
  export const themeColors = {
291
340
  text: (text) => paintRgb(currentPalette().text, text),
292
341
  textBold: (text) => paintRgb(currentPalette().textStrong, text, { bold: true }),
@@ -330,4 +379,25 @@ export const themeColors = {
330
379
  overlayBgChangelog: (text) => paintBg(currentPalette().overlayBg.changelog, text, currentPalette().overlayFg),
331
380
  overlayBgPlayground: (text) => paintBg(currentPalette().overlayBg.playground, text, currentPalette().overlayFg),
332
381
  overlayBgCommandPalette: (text) => paintBg(currentPalette().overlayBg.commandPalette, text, currentPalette().overlayFg),
382
+ /**
383
+ * 📖 Returns the active theme's background RGB array.
384
+ * 📖 Use this to paint lines with a forced background so the TUI looks correct
385
+ * 📖 regardless of the terminal's native dark/light mode.
386
+ */
387
+ bgFill: () => THEME_BG_RGB[activeTheme] ?? THEME_BG_RGB.dark,
388
+ }
389
+
390
+ /**
391
+ * 📖 Patch every \x1b[49m (bg reset) in an ANSI string by immediately
392
+ * 📖 re-applying the current theme's background colour. Chalk emits
393
+ * 📖 \x1b[49m after every bgRgb() call, which undoes our forced theme bg.
394
+ *
395
+ * 📖 Also handles \x1b[0m (full reset) which includes a bg reset.
396
+ */
397
+ export function patchThemeBg(raw, bgRgb = null) {
398
+ const color = bgRgb ?? THEME_BG_RGB[activeTheme] ?? THEME_BG_RGB.dark
399
+ const BG_SET = `\x1b[48;2;${color[0]};${color[1]};${color[2]}m`
400
+ return raw
401
+ .replace(/\x1b\[0m/g, `\x1b[0m${BG_SET}`)
402
+ .replace(/\x1b\[49m/g, `\x1b[49m${BG_SET}`)
333
403
  }
package/web/README.md CHANGED
@@ -36,7 +36,7 @@ To start the React frontend with Vite HMR (Hot Module Replacement):
36
36
  cd web
37
37
  pnpm dev
38
38
  ```
39
- By default, the dev server runs on `http://localhost:5173/`. Ensure a background daemon is running on port `19280` so the API requests proxy correctly.
39
+ By default, the dev server runs on `http://localhost:5179/`. Ensure a background daemon is running on port `19280` so the API requests proxy correctly.
40
40
 
41
41
  ### 2. Production Build
42
42
  To compile the production-ready SPA: