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.
- package/changelog/v0.5.12.md +86 -2
- package/changelog/v0.5.13.md +19 -53
- package/changelog/v0.5.15.md +17 -0
- package/package.json +4 -3
- package/src/tui/app.js +16 -4
- package/src/tui/cli-help.js +87 -0
- package/src/tui/key-handler.js +2 -14
- package/src/tui/render-table.js +46 -26
- package/src/tui/theme.js +70 -0
- package/web/README.md +1 -1
- package/web/dist/assets/index-BRqzsHVw.css +1 -0
- package/web/dist/assets/index-CUYQh5_t.js +39 -0
- package/web/dist/index.html +2 -2
- package/web/server.js +83 -14
- package/web/src/App.jsx +15 -1
- package/web/src/components/dashboard/DetailPanel.jsx +1 -1
- package/web/src/components/dashboard/DetailPanel.module.css +5 -0
- package/web/src/components/dashboard/FilterBar.jsx +98 -58
- package/web/src/components/dashboard/FilterBar.module.css +103 -33
- package/web/src/components/dashboard/ProviderDropdown.jsx +156 -0
- package/web/src/components/dashboard/ProviderDropdown.module.css +248 -0
- package/web/src/components/help/HelpView.jsx +13 -0
- package/web/src/components/playground/PlaygroundView.jsx +193 -12
- package/web/src/components/playground/PlaygroundView.module.css +82 -0
- package/web/src/components/router/RouterView.jsx +15 -6
- package/web/vite.config.js +1 -1
- package/web/dist/assets/index-Bzqz7KbT.js +0 -39
- package/web/dist/assets/index-H9JWDRIh.css +0 -1
package/changelog/v0.5.12.md
CHANGED
|
@@ -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)
|
|
59
|
-
|
|
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
|
+
```
|
package/changelog/v0.5.13.md
CHANGED
|
@@ -1,57 +1,23 @@
|
|
|
1
|
-
# Changelog v0.5.13 - 2026-06-
|
|
1
|
+
# Changelog v0.5.13 - 2026-06-04
|
|
2
2
|
|
|
3
3
|
### Added
|
|
4
|
-
- **
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
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
|
-
###
|
|
37
|
-
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
###
|
|
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.
|
|
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.
|
|
80
|
-
"react-dom": "^19.2.
|
|
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
|
-
|
|
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
|
}
|
package/src/tui/cli-help.js
CHANGED
|
@@ -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
|
}
|
package/src/tui/key-handler.js
CHANGED
|
@@ -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
|
|
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
|
|
package/src/tui/render-table.js
CHANGED
|
@@ -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(
|
|
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
|
-
` ${
|
|
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
|
|
463
|
-
|
|
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
|
-
|
|
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(
|
|
500
|
-
if (sortColumn === 'verdict') return chalk.bold.rgb(
|
|
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(
|
|
692
|
+
? chalk.rgb(...currentPalette().ctxGold).bold(ctxRaw.padEnd(W_CTX))
|
|
685
693
|
: numK <= 256
|
|
686
|
-
? chalk.rgb(
|
|
694
|
+
? chalk.rgb(...currentPalette().ctxGreen).bold(ctxRaw.padEnd(W_CTX))
|
|
687
695
|
: numK <= 400
|
|
688
|
-
? chalk.rgb(
|
|
689
|
-
: chalk.rgb(
|
|
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(
|
|
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(
|
|
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(
|
|
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.
|
|
1101
|
-
: chalk.bgRgb(
|
|
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(
|
|
1153
|
+
? chalk.rgb(...currentPalette().releaseDate)(`Last release: ${lastReleaseDate}`)
|
|
1142
1154
|
: ''
|
|
1143
|
-
const speedTestLabel = chalk.bgRgb(
|
|
1144
|
-
const globalBenchmarkLabel = chalk.bgRgb(
|
|
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
|
-
// 📖
|
|
1169
|
-
// 📖
|
|
1170
|
-
// 📖
|
|
1171
|
-
|
|
1172
|
-
|
|
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
|
}
|