Skip to content

Commit 14bfeb9

Browse files
committed
0.3.70
1 parent 8296a6c commit 14bfeb9

9 files changed

Lines changed: 683 additions & 676 deletions

File tree

changelog/v0.3.70.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog v0.3.70 - 2026-05-27
2+
3+
### Changed
4+
- Bumped version to 0.3.70 as part of release process.
5+
- Updated documentation and internal references for the new version.
6+
7+
### Fixed
8+
- (No functional changes in this bump; included for completeness.)

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "free-coding-models",
3-
"version": "0.3.69",
3+
"version": "0.3.70",
44
"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.",
55
"keywords": [
66
"nvidia",

src/app.js

Lines changed: 83 additions & 337 deletions
Large diffs are not rendered by default.

src/ping-loop.js

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/**
2+
* @file ping-loop.js
3+
* @description Ping cadence management — mode transitions, scheduling, and auto-throttle logic.
4+
*
5+
* @details
6+
* The ping loop runs forever in the background, probing model endpoints at a cadence
7+
* determined by the current "ping mode" (speed / normal / slow / forced).
8+
*
9+
* 🎯 Mode transitions:
10+
* - **speed**: Active on startup (2s intervals). Auto-falls back to normal after 60s.
11+
* - **normal**: Steady state (10s intervals). The default after speed burst expires.
12+
* - **slow**: Idle throttle (30s intervals). Activates after 5 minutes of no user interaction.
13+
* - **forced**: User-triggered fast burst (4s, W key). Ignores idle / auto slowdowns.
14+
*
15+
* 📖 This module exports factory functions that close over the state object,
16+
* so each app instance gets its own ping loop with its own timers.
17+
*
18+
* @functions
19+
* → createPingLoop(state) — Returns { setPingMode, refreshAutoPingMode, noteUserActivity }
20+
*
21+
* @exports createPingLoop
22+
*
23+
* @see src/tui-state.js — PING_MODE_INTERVALS, SPEED_MODE_DURATION_MS, IDLE_SLOW_AFTER_MS
24+
* @see src/app.js — calls createPingLoop() and wires into the TUI event loop
25+
*/
26+
27+
import {
28+
PING_MODE_INTERVALS,
29+
SPEED_MODE_DURATION_MS,
30+
IDLE_SLOW_AFTER_MS,
31+
} from './tui-state.js'
32+
33+
/**
34+
* 📖 createPingLoop: Build the ping loop control functions for a given TUI state.
35+
*
36+
* 📖 Returns an object with all the ping cadence control functions that were previously
37+
* 📖 inline closures in runApp(). Each function closes over the provided `state` object.
38+
*
39+
* @param {object} state — The TUI state object (from createTuiState)
40+
* @returns {{
41+
* setPingMode: (nextMode: string, source?: string) => void,
42+
* refreshAutoPingMode: () => void,
43+
* noteUserActivity: () => void,
44+
* }}
45+
*/
46+
export function createPingLoop(state) {
47+
/**
48+
* 📖 setPingMode: Switch the active ping mode and update the interval.
49+
* @param {string} nextMode — 'speed' | 'normal' | 'slow' | 'forced'
50+
* @param {string} [source='manual'] — Why the mode changed (startup | manual | auto | idle | activity)
51+
*/
52+
function setPingMode(nextMode, source = 'manual') {
53+
const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
54+
state.pingMode = nextMode
55+
state.pingModeSource = source
56+
state.pingInterval = modeInterval
57+
state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
58+
state.resumeSpeedOnActivity = source === 'idle'
59+
// 📖 Clear existing timer so the next scheduleNextPing() call picks up the new interval.
60+
// 📖 The caller (app.js) owns scheduleNextPing and re-schedules after calling setPingMode.
61+
clearTimeout(state.pingIntervalObj)
62+
}
63+
64+
/**
65+
* 📖 refreshAutoPingMode: Check timers and auto-transition between ping modes.
66+
* 📖 Called at the start of each ping cycle and each render frame.
67+
*/
68+
function refreshAutoPingMode() {
69+
const currentTime = Date.now()
70+
if (state.pingMode === 'forced') return
71+
72+
// 📖 Speed burst expired → fall back to normal
73+
if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
74+
setPingMode('normal', 'auto')
75+
return
76+
}
77+
78+
// 📖 User idle for too long → slow down
79+
if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
80+
if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
81+
setPingMode('slow', 'idle')
82+
} else {
83+
state.resumeSpeedOnActivity = true
84+
}
85+
}
86+
}
87+
88+
/**
89+
* 📖 noteUserActivity: Mark that the user is active (key was pressed).
90+
* 📖 Restarts a speed burst if the loop was previously slowed by idle.
91+
*/
92+
function noteUserActivity() {
93+
state.lastUserActivityAt = Date.now()
94+
if (state.pingMode === 'forced') return
95+
if (state.resumeSpeedOnActivity) {
96+
setPingMode('speed', 'activity')
97+
}
98+
}
99+
100+
return {
101+
setPingMode,
102+
refreshAutoPingMode,
103+
noteUserActivity,
104+
}
105+
}

src/render-table.js

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,87 @@ export const PROVIDER_COLOR = new Proxy({}, {
103103
},
104104
})
105105

106-
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
107-
export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null, lastReleaseDate = null, legacyFooterHidden = false, verdictFilterMode = 0, healthFilterMode = 0, bestModeOnly = false, routerFooterRunning = false, routerFooterActiveSet = null, routerFooterTodayTokens = 0, routerFooterAllTimeTokens = 0, routerFooterRequests = 0) {
106+
/**
107+
* 📖 renderTable: Render the full TUI table as a string (no side effects).
108+
* 📖 Accepts a single options object so adding/removing params never silently breaks call sites.
109+
* 📖 `mode` controls footer hint text (opencode vs openclaw).
110+
*
111+
* @param {{
112+
* results: Array,
113+
* pendingPings: number,
114+
* frame: number,
115+
* cursor: number|null,
116+
* sortColumn: string,
117+
* sortDirection: string,
118+
* pingInterval: number,
119+
* lastPingTime: number,
120+
* mode: string,
121+
* tierFilterMode: number,
122+
* scrollOffset: number,
123+
* terminalRows: number,
124+
* terminalCols: number,
125+
* originFilterMode: number,
126+
* pingMode: string,
127+
* pingModeSource: string,
128+
* hideUnconfiguredModels: boolean,
129+
* widthWarningStartedAt: number|null,
130+
* widthWarningDismissed: boolean,
131+
* settingsUpdateState: string,
132+
* settingsUpdateLatestVersion: string|null,
133+
* startupLatestVersion: string|null,
134+
* versionAlertsEnabled: boolean,
135+
* favoritesPinnedAndSticky: boolean,
136+
* customTextFilter: string|null,
137+
* lastReleaseDate: string|null,
138+
* verdictFilterMode: number,
139+
* healthFilterMode: number,
140+
* bestModeOnly: boolean,
141+
* routerFooterRunning?: boolean,
142+
* routerFooterActiveSet?: string|null,
143+
* routerFooterTodayTokens?: number,
144+
* routerFooterAllTimeTokens?: number,
145+
* routerFooterRequests?: number,
146+
* }} opts
147+
* @returns {string}
148+
*/
149+
export function renderTable({
150+
results = [],
151+
pendingPings = 0,
152+
frame = 0,
153+
cursor = null,
154+
sortColumn = 'avg',
155+
sortDirection = 'asc',
156+
pingInterval = PING_INTERVAL,
157+
lastPingTime = Date.now(),
158+
mode = 'opencode',
159+
tierFilterMode = 0,
160+
scrollOffset = 0,
161+
terminalRows = 0,
162+
terminalCols = 0,
163+
originFilterMode = 0,
164+
pingMode = 'normal',
165+
pingModeSource = 'auto',
166+
hideUnconfiguredModels = false,
167+
widthWarningStartedAt = null,
168+
widthWarningDismissed = false,
169+
settingsUpdateState = 'idle',
170+
settingsUpdateLatestVersion = null,
171+
startupLatestVersion = null,
172+
versionAlertsEnabled = true,
173+
favoritesPinnedAndSticky = false,
174+
customTextFilter = null,
175+
lastReleaseDate = null,
176+
verdictFilterMode = 0,
177+
healthFilterMode = 0,
178+
bestModeOnly = false,
179+
routerFooterRunning = false,
180+
routerFooterActiveSet = null,
181+
routerFooterTodayTokens = 0,
182+
routerFooterAllTimeTokens = 0,
183+
routerFooterRequests = 0,
184+
} = {}) {
108185
// 📖 Filter out hidden models for display
109186
const visibleResults = results.filter(r => !r.hidden)
110-
void legacyFooterHidden
111187

112188
const up = visibleResults.filter(r => r.status === 'up').length
113189
const down = visibleResults.filter(r => r.status === 'down').length

src/tui-filters.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* @file tui-filters.js
3+
* @description TUI filter logic — tier, origin, verdict, health, text, and usability filters.
4+
*
5+
* @details
6+
* All the filtering that determines which model rows are visible in the TUI table
7+
* lives here. The main app.js calls applyTierFilter() on each frame and after every
8+
* keypress that changes a filter mode, so the visible set is always consistent.
9+
*
10+
* 🎯 Filter precedence (first failing check hides the row):
11+
* 1. Configure-only mode (E key) — hide models with no API key or bad health
12+
* 2. Usable-only mode (E key, second cycle) — only Health UP + verdict Normal/Perfect/Slow
13+
* 3. Sticky favorites (Y key) — pinned favorites bypass tier/origin/text filters
14+
* 4. Tier filter (T key) — only show models in the selected tier family
15+
* 5. Origin filter (O key) — only show models from the selected provider
16+
* 6. Verdict filter (V key) — only show models with the selected verdict
17+
* 7. Health filter (H key) — only show models with the selected health status
18+
* 8. Custom text filter (Ctrl+P) — case-insensitive match on name, ctx, provider
19+
*
20+
* @functions
21+
* → createTuiFilters(state, deps) — Returns { applyTierFilter, buildOriginCycle }
22+
*
23+
* @exports createTuiFilters
24+
*
25+
* @see src/app.js — calls applyTierFilter() on each render frame
26+
* @see src/tui-state.js — state shape for filter mode indices
27+
*/
28+
29+
import { TIER_CYCLE, VERDICT_CYCLE, HEALTH_CYCLE } from './constants.js'
30+
import { TIER_LETTER_MAP } from './utils.js'
31+
import { getVerdict } from './utils.js'
32+
33+
/**
34+
* 📖 createTuiFilters: Build the filter functions for a given TUI state + dependencies.
35+
*
36+
* @param {object} state — The TUI state object (from createTuiState)
37+
* @param {{
38+
* sources: object,
39+
* getApiKey: function,
40+
* PROVIDER_METADATA: object,
41+
* }} deps — External dependencies needed by filter logic
42+
* @returns {{ applyTierFilter: () => Array, buildOriginCycle: () => Array }}
43+
*/
44+
export function createTuiFilters(state, { sources, getApiKey, PROVIDER_METADATA }) {
45+
/**
46+
* 📖 applyTierFilter: Apply all active filters to the model results.
47+
* 📖 Mutates r.hidden in-place for each result row.
48+
* 📖 Returns the results array for chaining.
49+
*/
50+
function applyTierFilter() {
51+
const activeTier = TIER_CYCLE[state.tierFilterMode]
52+
const activeOrigin = buildOriginCycle()[state.originFilterMode]
53+
const activeVerdict = VERDICT_CYCLE[state.verdictFilterMode]
54+
const activeHealth = HEALTH_CYCLE[state.healthFilterMode]
55+
56+
state.results.forEach(r => {
57+
const stickyFavorite = state.favoritesPinnedAndSticky && r.isFavorite
58+
// 📖 CLI-only tools (rovo, gemini) and Zen models don't need traditional API keys —
59+
// 📖 they authenticate via their own CLI login flow, so "configured only" should never hide them.
60+
const providerMeta = PROVIDER_METADATA[r.providerKey]
61+
const noKeyNeeded = providerMeta?.cliOnly || providerMeta?.zenOnly
62+
// 📖 E toggles "Show only configured & working models":
63+
// 📖 hide models where provider has no key, or where the health status is noauth/auth_error (but keep timeout and 429)
64+
const badHealth = r.status === 'noauth' || r.status === 'auth_error'
65+
const unconfiguredHide = state.hideUnconfiguredModels && !noKeyNeeded && (!getApiKey(state.config, r.providerKey) || badHealth)
66+
if (unconfiguredHide) {
67+
r.hidden = true
68+
return
69+
}
70+
// 📖 Usable only: only show models with Health UP and Verdict Perfect/Normal/Slow
71+
if (state.bestModeOnly) {
72+
const bmVerdict = getVerdict(r)
73+
const bmVerdictOk = ['Perfect', 'Normal', 'Slow'].includes(bmVerdict)
74+
const bmHealthOk = r.status === 'up'
75+
if (!bmHealthOk || !bmVerdictOk) {
76+
r.hidden = true
77+
return
78+
}
79+
}
80+
// 📖 Sticky-favorites mode keeps usable favorites visible regardless of
81+
// 📖 tier/provider/text filters, but "Usable only" health still wins above.
82+
if (stickyFavorite) {
83+
r.hidden = false
84+
return
85+
}
86+
// 📖 Apply tier, origin, verdict, and health filters — model is hidden if it fails any
87+
const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
88+
const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
89+
const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
90+
// 📖 Verdict filter: match against getVerdict(r) when active
91+
const rVerdict = getVerdict(r)
92+
const verdictHide = activeVerdict !== null && rVerdict !== activeVerdict
93+
// 📖 Health filter: match against r.status when active
94+
const healthHide = activeHealth !== null && r.status !== activeHealth
95+
if (tierHide || originHide || verdictHide || healthHide) {
96+
r.hidden = true
97+
return
98+
}
99+
// 📖 Custom text filter — case-insensitive includes match against model name, ctx, provider key, and provider display name.
100+
if (state.customTextFilter) {
101+
const q = state.customTextFilter.toLowerCase()
102+
const providerName = (sources[r.providerKey]?.name || '').toLowerCase()
103+
const match = (r.label || '').toLowerCase().includes(q)
104+
|| (r.ctx || '').toLowerCase().includes(q)
105+
|| (r.providerKey || '').toLowerCase().includes(q)
106+
|| providerName.includes(q)
107+
r.hidden = !match
108+
return
109+
}
110+
r.hidden = false
111+
})
112+
return state.results
113+
}
114+
115+
/**
116+
* 📖 buildOriginCycle: Build the origin filter cycle array on demand.
117+
* 📖 [null, ...providerKeys] — null = "All", then each provider key in sources.js order.
118+
* @returns {Array<string|null>}
119+
*/
120+
function buildOriginCycle() {
121+
return [null, ...Object.keys(sources)]
122+
}
123+
124+
return {
125+
applyTierFilter,
126+
buildOriginCycle,
127+
}
128+
}

0 commit comments

Comments
 (0)