free-coding-models 0.3.13 β 0.3.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.md +12 -0
- package/bin/free-coding-models.js +43 -926
- package/package.json +1 -1
- package/src/app.js +949 -0
- package/src/render-table.js +7 -19
|
@@ -2,945 +2,62 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* @file free-coding-models.js
|
|
4
4
|
* @description Live terminal availability checker for coding LLM models with OpenCode & OpenClaw integration.
|
|
5
|
-
*
|
|
6
|
-
* @details
|
|
7
|
-
* This CLI tool discovers and benchmarks language models optimized for coding.
|
|
8
|
-
* It runs in an alternate screen buffer, pings all models in parallel, re-pings successful ones
|
|
9
|
-
* multiple times for reliable latency measurements, and prints a clean final table.
|
|
10
|
-
* During benchmarking, users can navigate with arrow keys and press Enter to act on the selected model.
|
|
11
|
-
*
|
|
12
|
-
* π― Key features:
|
|
13
|
-
* - Parallel pings across all models with animated real-time updates (multi-provider)
|
|
14
|
-
* - Continuous monitoring with 60-second ping intervals (never stops)
|
|
15
|
-
* - Rolling averages calculated from ALL successful pings since start
|
|
16
|
-
* - Best-per-tier highlighting with medals (π₯π₯π₯)
|
|
17
|
-
* - Interactive navigation with arrow keys directly in the table
|
|
18
|
-
* - Instant OpenCode / OpenClaw / external-tool action on Enter key press
|
|
19
|
-
* - Direct mode flags plus an in-app Z-cycle for the public launcher set
|
|
20
|
-
* - Automatic config detection and model setup for both tools
|
|
21
|
-
* - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
|
|
22
|
-
* - Multi-provider support via sources.js (NIM/Groq/Cerebras/OpenRouter/Hugging Face/Replicate/DeepInfra/... β extensible)
|
|
23
|
-
* - Settings screen (P key) to manage API keys, provider toggles, manual updates, and provider-key diagnostics
|
|
24
|
-
* - Install Endpoints flow (Y key) to push provider catalogs into OpenCode, OpenClaw, Crush, and Goose
|
|
25
|
-
* - Favorites system: toggle with F, pin rows to top, persist between sessions
|
|
26
|
-
* - Uptime percentage tracking (successful pings / total pings)
|
|
27
|
-
* - Sortable columns (R/O/M/L/A/S/C/H/V/B/U/G keys)
|
|
28
|
-
* - Tier filtering via T key (cycles S+βSβA+βAβA-βB+βBβCβAll)
|
|
29
|
-
*
|
|
30
|
-
* β Functions:
|
|
31
|
-
* - `loadConfig` / `saveConfig` / `getApiKey`: Multi-provider JSON config via lib/config.js
|
|
32
|
-
* - `getTelemetryDistinctId`: Generate/reuse a stable anonymous ID for telemetry
|
|
33
|
-
* - `getTelemetryTerminal`: Infer terminal family (Terminal.app, iTerm2, kitty, etc.)
|
|
34
|
-
* - `isTelemetryDebugEnabled` / `telemetryDebug`: Optional runtime telemetry diagnostics via env
|
|
35
|
-
* - `sendUsageTelemetry`: Fire-and-forget anonymous app-start event
|
|
36
|
-
* - `ensureFavoritesConfig` / `toggleFavoriteModel`: Persist and toggle pinned favorites
|
|
37
|
-
* - `promptApiKey`: Interactive wizard for first-time multi-provider API key setup
|
|
38
|
-
* - `buildPingRequest` / `ping`: Build provider-specific probe requests and measure latency
|
|
39
|
-
* - `renderTable`: Generate ASCII table with colored latency indicators and status emojis
|
|
40
|
-
* - `getAvg`: Calculate average latency from all successful pings
|
|
41
|
-
* - `getVerdict`: Determine verdict string based on average latency (Overloaded for 429)
|
|
42
|
-
* - `getUptime`: Calculate uptime percentage from ping history
|
|
43
|
-
* - `sortResults`: Sort models by various columns
|
|
44
|
-
* - `checkNvidiaNimConfig`: Check if NVIDIA NIM provider is configured in OpenCode
|
|
45
|
-
* - `isTcpPortAvailable` / `resolveOpenCodeTmuxPort`: Pick a safe OpenCode port when running in tmux
|
|
46
|
-
* - `startOpenCode`: Launch OpenCode CLI with selected model (configures if needed)
|
|
47
|
-
* - `startOpenCodeDesktop`: Set model in shared config & open OpenCode Desktop app
|
|
48
|
-
* - `loadOpenClawConfig` / `saveOpenClawConfig`: Manage ~/.openclaw/openclaw.json
|
|
49
|
-
* - `startOpenClaw`: Set selected model as default in OpenClaw config (remote, no launch)
|
|
50
|
-
* - `filterByTier`: Filter models by tier letter prefix (S, A, B, C)
|
|
51
|
-
* - `main`: Orchestrates CLI flow, wizard, ping loops, animation, and output
|
|
52
|
-
*
|
|
53
|
-
* π¦ Dependencies:
|
|
54
|
-
* - Node.js 18+ (native fetch)
|
|
55
|
-
* - chalk: Terminal styling and colors
|
|
56
|
-
* - readline: Interactive input handling
|
|
57
|
-
* - sources.js: Model definitions from all providers
|
|
58
|
-
*
|
|
59
|
-
* βοΈ Configuration:
|
|
60
|
-
* - API keys stored per-provider in ~/.free-coding-models.json (0600 perms)
|
|
61
|
-
* - Old ~/.free-coding-models plain-text auto-migrated as nvidia key on first run
|
|
62
|
-
* - Env vars override config: NVIDIA_API_KEY, GROQ_API_KEY, CEREBRAS_API_KEY, OPENROUTER_API_KEY, HUGGINGFACE_API_KEY/HF_TOKEN, REPLICATE_API_TOKEN, DEEPINFRA_API_KEY/DEEPINFRA_TOKEN, FIREWORKS_API_KEY, SILICONFLOW_API_KEY, TOGETHER_API_KEY, PERPLEXITY_API_KEY, ZAI_API_KEY, etc.
|
|
63
|
-
* - ZAI (z.ai) uses a non-standard base path; cloudflare needs CLOUDFLARE_ACCOUNT_ID in env.
|
|
64
|
-
* - Cloudflare Workers AI requires both CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_KEY) and CLOUDFLARE_ACCOUNT_ID
|
|
65
|
-
* - Models loaded from sources.js β all provider/model definitions are centralized there
|
|
66
|
-
* - OpenCode config: ~/.config/opencode/opencode.json
|
|
67
|
-
* - OpenClaw config: ~/.openclaw/openclaw.json
|
|
68
|
-
* - Ping timeout: 15s per attempt
|
|
69
|
-
* - Ping cadence: 2s startup burst for 60s, 10s steady state, 30s after 5m idle, forced 4s via `W`
|
|
70
|
-
* - Animation: 12 FPS with braille spinners
|
|
71
|
-
*
|
|
72
|
-
* π CLI flags:
|
|
73
|
-
* - (no flag): Start in OpenCode CLI mode
|
|
74
|
-
* - --opencode: OpenCode CLI mode (launch CLI with selected model)
|
|
75
|
-
* - --opencode-desktop: OpenCode Desktop mode (set model & open Desktop app)
|
|
76
|
-
* - --openclaw: OpenClaw mode (set selected model as default in OpenClaw)
|
|
77
|
-
* - --crush / --goose / --pi: launch the currently selected model in the supported external CLI
|
|
78
|
-
* - --best: Show only top-tier models (A+, S, S+)
|
|
79
|
-
* - --fiable: Analyze 10s and output the most reliable model
|
|
80
|
-
* - --json: Output results as JSON (for scripting/automation)
|
|
81
|
-
* - --recommend: Open Smart Recommend immediately on startup
|
|
82
|
-
* - --profile <name>: Load a saved config profile before entering the TUI
|
|
83
|
-
* - --no-telemetry: Disable anonymous usage analytics for this run
|
|
84
|
-
* - --help / -h: Print the full CLI help and exit
|
|
85
|
-
* - --tier S/A/B/C: Filter models by tier letter (S=S+/S, A=A+/A/A-, B=B+/B, C=C)
|
|
86
|
-
*
|
|
87
|
-
* @see {@link https://build.nvidia.com} NVIDIA API key generation
|
|
88
|
-
* @see {@link https://github.com/opencode-ai/opencode} OpenCode repository
|
|
89
|
-
* @see {@link https://openclaw.ai} OpenClaw documentation
|
|
90
5
|
*/
|
|
91
6
|
|
|
92
|
-
import chalk from 'chalk'
|
|
93
|
-
import {
|
|
94
|
-
import {
|
|
95
|
-
import {
|
|
96
|
-
import {
|
|
97
|
-
import {
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
import { stripAnsi, maskApiKey, displayWidth, padEndDisplay, tintOverlayLines, keepOverlayTargetVisible, sliceOverlayLines, calculateViewport, sortResultsWithPinnedFavorites, adjustScrollOffset } from '../src/render-helpers.js'
|
|
118
|
-
import { renderTable, PROVIDER_COLOR } from '../src/render-table.js'
|
|
119
|
-
import { setOpenCodeModelData, startOpenCode, startOpenCodeDesktop } from '../src/opencode.js'
|
|
120
|
-
import { startOpenClaw } from '../src/openclaw.js'
|
|
121
|
-
import { createOverlayRenderers } from '../src/overlays.js'
|
|
122
|
-
import { createKeyHandler } from '../src/key-handler.js'
|
|
123
|
-
import { getToolModeOrder, getToolMeta } from '../src/tool-metadata.js'
|
|
124
|
-
import { startExternalTool } from '../src/tool-launchers.js'
|
|
125
|
-
import { getConfiguredInstallableProviders, installProviderEndpoints, refreshInstalledEndpoints, getInstallTargetModes, getProviderCatalogModels } from '../src/endpoint-installer.js'
|
|
126
|
-
import { loadCache, saveCache, clearCache, getCacheAge } from '../src/cache.js'
|
|
127
|
-
import { checkConfigSecurity } from '../src/security.js'
|
|
128
|
-
import { buildCliHelpText } from '../src/cli-help.js'
|
|
129
|
-
|
|
130
|
-
// π mergedModels: cross-provider grouped model list (one entry per label, N providers each)
|
|
131
|
-
// π mergedModelByLabel: fast lookup map from display label β merged model entry
|
|
132
|
-
const mergedModels = buildMergedModels(MODELS)
|
|
133
|
-
const mergedModelByLabel = new Map(mergedModels.map(m => [m.label, m]))
|
|
134
|
-
setOpenCodeModelData(mergedModels, mergedModelByLabel)
|
|
135
|
-
|
|
136
|
-
// π Provider quota cache is managed by lib/provider-quota-fetchers.js (TTL + backoff).
|
|
137
|
-
// π Usage placeholder logic uses isKnownQuotaTelemetry() from lib/quota-capabilities.js.
|
|
138
|
-
|
|
139
|
-
const require = createRequire(import.meta.url)
|
|
140
|
-
const readline = require('readline')
|
|
141
|
-
|
|
142
|
-
// βββ Version check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
143
|
-
const pkg = require('../package.json')
|
|
144
|
-
const LOCAL_VERSION = pkg.version
|
|
145
|
-
|
|
146
|
-
// π sendBugReport β imported from ../src/telemetry.js
|
|
147
|
-
|
|
148
|
-
// π parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug, ensureTelemetryConfig β imported from ../src/telemetry.js
|
|
149
|
-
|
|
150
|
-
// π ensureFavoritesConfig, toFavoriteKey, syncFavoriteFlags, toggleFavoriteModel β imported from ../src/favorites.js
|
|
151
|
-
|
|
152
|
-
// βββ Alternate screen control βββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
153
|
-
// π \x1b[?1049h = enter alt screen \x1b[?1049l = leave alt screen
|
|
154
|
-
// π \x1b[?25l = hide cursor \x1b[?25h = show cursor
|
|
155
|
-
// π \x1b[H = cursor to top
|
|
156
|
-
// π NOTE: We avoid \x1b[2J (clear screen) because Ghostty scrolls cleared
|
|
157
|
-
// π content into the scrollback on the alt screen, pushing the header off-screen.
|
|
158
|
-
// π Instead we overwrite in place: cursor home, then \x1b[K (erase to EOL) per line.
|
|
159
|
-
// π \x1b[?7l disables auto-wrap so wide rows clip at the right edge instead of
|
|
160
|
-
// π wrapping to the next line (which would double the row height and overflow).
|
|
161
|
-
// NOTE: All constants (ALT_ENTER, PING_TIMEOUT, etc.) are imported from ../src/constants.js
|
|
162
|
-
|
|
163
|
-
// βββ Styling ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
164
|
-
// π Tier colors (TIER_COLOR) are imported from ../src/tier-colors.js
|
|
165
|
-
// π All TUI constants (ALT_ENTER, PING_TIMEOUT, etc.) are imported from ../src/constants.js
|
|
166
|
-
|
|
167
|
-
// π renderTable is now extracted to ../src/render-table.js
|
|
168
|
-
|
|
169
|
-
// βββ OpenCode integration ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
170
|
-
// π OpenCode helpers are imported from ../src/opencode.js
|
|
171
|
-
|
|
172
|
-
// βββ OpenCode integration ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
173
|
-
// π OpenCode helpers are imported from ../src/opencode.js
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
import { parseArgs, TIER_LETTER_MAP } from '../src/utils.js';
|
|
9
|
+
import { loadConfig } from '../src/config.js';
|
|
10
|
+
import { ensureTelemetryConfig } from '../src/telemetry.js';
|
|
11
|
+
import { ensureFavoritesConfig } from '../src/favorites.js';
|
|
12
|
+
import { buildCliHelpText } from '../src/cli-help.js';
|
|
13
|
+
import { ALT_LEAVE } from '../src/constants.js';
|
|
14
|
+
import { runApp } from '../src/app.js';
|
|
15
|
+
|
|
16
|
+
// Global error handlers to ensure terminal is restored if something crashes catastrophically
|
|
17
|
+
process.on('uncaughtException', (err) => {
|
|
18
|
+
process.stdout.write(ALT_LEAVE);
|
|
19
|
+
console.error(chalk.red('\n[Fatal Error] An unhandled exception occurred.'));
|
|
20
|
+
console.error(err);
|
|
21
|
+
console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
|
|
22
|
+
process.exit(1);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
process.on('unhandledRejection', (reason, promise) => {
|
|
26
|
+
process.stdout.write(ALT_LEAVE);
|
|
27
|
+
console.error(chalk.red('\n[Fatal Error] An unhandled promise rejection occurred.'));
|
|
28
|
+
console.error(reason);
|
|
29
|
+
console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
174
32
|
|
|
175
33
|
async function main() {
|
|
176
|
-
const cliArgs = parseArgs(process.argv)
|
|
34
|
+
const cliArgs = parseArgs(process.argv);
|
|
177
35
|
|
|
178
36
|
if (cliArgs.helpMode) {
|
|
179
|
-
console.log()
|
|
180
|
-
console.log(buildCliHelpText({ chalk, title: 'free-coding-models' }))
|
|
181
|
-
console.log()
|
|
182
|
-
process.exit(0)
|
|
37
|
+
console.log();
|
|
38
|
+
console.log(buildCliHelpText({ chalk, title: 'free-coding-models' }));
|
|
39
|
+
console.log();
|
|
40
|
+
process.exit(0);
|
|
183
41
|
}
|
|
184
42
|
|
|
185
43
|
// Validate --tier early, before entering alternate screen
|
|
186
44
|
if (cliArgs.tierFilter && !TIER_LETTER_MAP[cliArgs.tierFilter]) {
|
|
187
|
-
console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`))
|
|
188
|
-
process.exit(1)
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// π Load JSON config (auto-migrates old plain-text ~/.free-coding-models if needed)
|
|
192
|
-
const config = loadConfig()
|
|
193
|
-
ensureTelemetryConfig(config)
|
|
194
|
-
ensureFavoritesConfig(config)
|
|
195
|
-
|
|
196
|
-
// π Check config file security β warn and offer auto-fix if permissions are too open
|
|
197
|
-
const securityCheck = checkConfigSecurity()
|
|
198
|
-
if (!securityCheck.wasSecure && !securityCheck.wasFixed) {
|
|
199
|
-
// π User declined auto-fix or it failed β continue anyway, just warned
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// π Apply CLI overrides for settings
|
|
203
|
-
if (cliArgs.sortColumn) config.settings.sortColumn = cliArgs.sortColumn
|
|
204
|
-
if (cliArgs.sortDirection) config.settings.sortAsc = cliArgs.sortDirection === 'asc'
|
|
205
|
-
if (cliArgs.originFilter) config.settings.originFilter = cliArgs.originFilter
|
|
206
|
-
if (cliArgs.pingInterval) config.settings.pingInterval = cliArgs.pingInterval
|
|
207
|
-
if (cliArgs.hideUnconfigured) config.settings.hideUnconfiguredModels = true
|
|
208
|
-
if (cliArgs.showUnconfigured) config.settings.hideUnconfiguredModels = false
|
|
209
|
-
if (cliArgs.disableWidthsWarning) config.settings.disableWidthsWarning = true
|
|
210
|
-
|
|
211
|
-
// π Apply premium mode: show only Sβtier models sorted by verdict
|
|
212
|
-
if (cliArgs.premiumMode) {
|
|
213
|
-
config.settings.tierFilter = 'S'
|
|
214
|
-
config.settings.sortColumn = 'verdict'
|
|
215
|
-
config.settings.sortAsc = true
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// π Profile system removed - API keys now persist permanently across all sessions
|
|
219
|
-
|
|
220
|
-
// π Check if any provider has a key β if not, run the first-time setup wizard
|
|
221
|
-
const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
|
|
222
|
-
|
|
223
|
-
if (!hasAnyKey) {
|
|
224
|
-
const result = await promptApiKey(config)
|
|
225
|
-
if (!result) {
|
|
226
|
-
console.log()
|
|
227
|
-
console.log(chalk.red(' β No API key provided.'))
|
|
228
|
-
console.log(chalk.dim(' Run `free-coding-models` again or set NVIDIA_API_KEY / GROQ_API_KEY / CEREBRAS_API_KEY.'))
|
|
229
|
-
console.log()
|
|
230
|
-
process.exit(1)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// π Default mode: use the last persisted launcher choice when valid,
|
|
235
|
-
// π otherwise fall back to OpenCode CLI.
|
|
236
|
-
let mode = getToolModeOrder().includes(config.settings?.preferredToolMode)
|
|
237
|
-
? config.settings.preferredToolMode
|
|
238
|
-
: 'opencode'
|
|
239
|
-
const requestedMode = getToolModeOrder().find((toolMode) => {
|
|
240
|
-
const flagByMode = {
|
|
241
|
-
opencode: cliArgs.openCodeMode,
|
|
242
|
-
'opencode-desktop': cliArgs.openCodeDesktopMode,
|
|
243
|
-
openclaw: cliArgs.openClawMode,
|
|
244
|
-
aider: cliArgs.aiderMode,
|
|
245
|
-
crush: cliArgs.crushMode,
|
|
246
|
-
goose: cliArgs.gooseMode,
|
|
247
|
-
qwen: cliArgs.qwenMode,
|
|
248
|
-
openhands: cliArgs.openHandsMode,
|
|
249
|
-
amp: cliArgs.ampMode,
|
|
250
|
-
pi: cliArgs.piMode,
|
|
251
|
-
}
|
|
252
|
-
return flagByMode[toolMode] === true
|
|
253
|
-
})
|
|
254
|
-
if (requestedMode) mode = requestedMode
|
|
255
|
-
|
|
256
|
-
// π Track app opening early so fast exits are still counted.
|
|
257
|
-
// π Must run before update checks because npm registry lookups can add startup delay.
|
|
258
|
-
void sendUsageTelemetry(config, cliArgs, {
|
|
259
|
-
event: 'app_start',
|
|
260
|
-
version: LOCAL_VERSION,
|
|
261
|
-
mode,
|
|
262
|
-
ts: new Date().toISOString(),
|
|
263
|
-
})
|
|
264
|
-
|
|
265
|
-
// π Auto-update detection: check npm registry for new versions at startup.
|
|
266
|
-
// π If a new version is available, show an interactive prompt (Update / Changelogs / Skip).
|
|
267
|
-
// π Dev mode (git checkout) skips auto-update to avoid infinite relaunch loops.
|
|
268
|
-
let latestVersion = null
|
|
269
|
-
const isDevMode = existsSync(join(dirname(fileURLToPath(import.meta.url)), '..', '.git'))
|
|
270
|
-
try {
|
|
271
|
-
latestVersion = await checkForUpdate()
|
|
272
|
-
// π Reset failure counter on successful check
|
|
273
|
-
if (config.settings?.updateCheckFailures) {
|
|
274
|
-
config.settings.updateCheckFailures = 0
|
|
275
|
-
saveConfig(config)
|
|
276
|
-
}
|
|
277
|
-
} catch (err) {
|
|
278
|
-
const failures = (config.settings?.updateCheckFailures || 0) + 1
|
|
279
|
-
if (!config.settings) config.settings = {}
|
|
280
|
-
config.settings.updateCheckFailures = Math.min(failures, 3)
|
|
281
|
-
saveConfig(config)
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// π Show interactive update prompt if a new version is available (skip in dev mode)
|
|
285
|
-
if (latestVersion && !isDevMode) {
|
|
286
|
-
const choice = await promptUpdateNotification(latestVersion)
|
|
287
|
-
if (choice === 'update') {
|
|
288
|
-
runUpdate(latestVersion)
|
|
289
|
-
return // π runUpdate relaunches the process β this line is a safety guard
|
|
290
|
-
} else if (choice === 'changelogs') {
|
|
291
|
-
const { execSync: _exec } = await import('child_process')
|
|
292
|
-
const url = 'https://github.com/vava-nessa/free-coding-models/releases'
|
|
293
|
-
try {
|
|
294
|
-
if (process.platform === 'darwin') _exec(`open ${url}`)
|
|
295
|
-
else if (process.platform === 'linux') _exec(`xdg-open ${url}`)
|
|
296
|
-
else console.log(chalk.dim(` π ${url}`))
|
|
297
|
-
} catch { console.log(chalk.dim(` π ${url}`)) }
|
|
298
|
-
// π After opening changelogs, re-prompt so user can still update or continue
|
|
299
|
-
const choice2 = await promptUpdateNotification(latestVersion)
|
|
300
|
-
if (choice2 === 'update') {
|
|
301
|
-
runUpdate(latestVersion)
|
|
302
|
-
return
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// π Dynamic OpenRouter free model discovery β fetch live free models from API
|
|
308
|
-
// π Replaces static openrouter entries in MODELS with fresh data.
|
|
309
|
-
// π Fallback: if fetch fails, the static list from sources.js stays intact + warning shown.
|
|
310
|
-
const dynamicModels = await fetchOpenRouterFreeModels()
|
|
311
|
-
if (dynamicModels) {
|
|
312
|
-
// π Remove all existing openrouter entries from MODELS
|
|
313
|
-
for (let i = MODELS.length - 1; i >= 0; i--) {
|
|
314
|
-
if (MODELS[i][5] === 'openrouter') MODELS.splice(i, 1)
|
|
315
|
-
}
|
|
316
|
-
// π Push fresh entries with 'openrouter' providerKey
|
|
317
|
-
for (const [modelId, label, tier, swe, ctx] of dynamicModels) {
|
|
318
|
-
MODELS.push([modelId, label, tier, swe, ctx, 'openrouter'])
|
|
319
|
-
}
|
|
320
|
-
} else {
|
|
321
|
-
console.log(chalk.yellow(' OpenRouter: using cached model list (live fetch failed)'))
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// π Re-sync tracked external-tool catalogs after the live provider catalog has settled.
|
|
325
|
-
// π This keeps prior `Y` installs aligned with the current FCM model list.
|
|
326
|
-
refreshInstalledEndpoints(config)
|
|
327
|
-
|
|
328
|
-
// π Build results from MODELS β only include enabled providers
|
|
329
|
-
// π Each result gets providerKey so ping() knows which URL + API key to use
|
|
330
|
-
|
|
331
|
-
let results = MODELS
|
|
332
|
-
.filter(([,,,,,providerKey]) => isProviderEnabled(config, providerKey))
|
|
333
|
-
.map(([modelId, label, tier, sweScore, ctx, providerKey], i) => ({
|
|
334
|
-
idx: i + 1, modelId, label, tier, sweScore, ctx, providerKey,
|
|
335
|
-
status: 'pending',
|
|
336
|
-
pings: [], // π All ping results (ms or 'TIMEOUT')
|
|
337
|
-
httpCode: null,
|
|
338
|
-
isPinging: false, // π Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
|
|
339
|
-
hidden: false, // π Simple flag to hide/show models
|
|
340
|
-
}))
|
|
341
|
-
syncFavoriteFlags(results, config)
|
|
342
|
-
|
|
343
|
-
// π Load usage data from token-stats.json and attach usagePercent to each result row.
|
|
344
|
-
// π usagePercent is the quota percent remaining (0β100). undefined = no data available.
|
|
345
|
-
// π Freshness-aware: snapshots older than 30 minutes are excluded (shown as N/A in UI).
|
|
346
|
-
const tokenTotalsByProviderModel = loadTokenUsageByProviderModel()
|
|
347
|
-
for (const r of results) {
|
|
348
|
-
const pct = _usageForRow(r.providerKey, r.modelId)
|
|
349
|
-
r.usagePercent = typeof pct === 'number' ? pct : undefined
|
|
350
|
-
r.totalTokens = tokenTotalsByProviderModel[buildProviderModelTokenKey(r.providerKey, r.modelId)] || 0
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// π Add interactive selection state - cursor index and user's choice
|
|
354
|
-
// π sortColumn: 'rank'|'tier'|'origin'|'model'|'ping'|'avg'|'status'|'verdict'|'uptime'
|
|
355
|
-
// π sortDirection: 'asc' (default) or 'desc'
|
|
356
|
-
// π ping cadence is now mode-driven:
|
|
357
|
-
// π speed = 2s for 1 minute bursts
|
|
358
|
-
// π normal = 10s steady state
|
|
359
|
-
// π slow = 30s after 5 minutes of inactivity
|
|
360
|
-
// π forced = 4s and ignores inactivity / auto slowdowns
|
|
361
|
-
const PING_MODE_INTERVALS = {
|
|
362
|
-
speed: 2_000,
|
|
363
|
-
normal: 10_000,
|
|
364
|
-
slow: 30_000,
|
|
365
|
-
forced: 4_000,
|
|
366
|
-
}
|
|
367
|
-
const PING_MODE_CYCLE = ['speed', 'normal', 'slow', 'forced']
|
|
368
|
-
const SPEED_MODE_DURATION_MS = 60_000
|
|
369
|
-
const IDLE_SLOW_AFTER_MS = 5 * 60_000
|
|
370
|
-
const now = Date.now()
|
|
371
|
-
|
|
372
|
-
const intervalToPingMode = (intervalMs) => {
|
|
373
|
-
if (intervalMs <= 3000) return 'speed'
|
|
374
|
-
if (intervalMs <= 5000) return 'forced'
|
|
375
|
-
if (intervalMs >= 30000) return 'slow'
|
|
376
|
-
return 'normal'
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// π tierFilter: current tier filter letter (null = all, 'S' = S+/S, 'A' = A+/A/A-, etc.)
|
|
380
|
-
const state = {
|
|
381
|
-
results,
|
|
382
|
-
pendingPings: 0,
|
|
383
|
-
frame: 0,
|
|
384
|
-
cursor: 0,
|
|
385
|
-
selectedModel: null,
|
|
386
|
-
sortColumn: config.settings?.sortColumn ?? 'avg',
|
|
387
|
-
sortDirection: (config.settings?.sortAsc ?? true) ? 'asc' : 'desc',
|
|
388
|
-
pingInterval: PING_MODE_INTERVALS.speed, // π Effective live interval derived from the active ping mode.
|
|
389
|
-
pingMode: 'speed', // π Current ping mode: speed | normal | slow | forced.
|
|
390
|
-
pingModeSource: 'startup', // π Why this mode is active: startup | manual | auto | idle | activity.
|
|
391
|
-
speedModeUntil: now + SPEED_MODE_DURATION_MS, // π Speed bursts auto-fall back to normal after 60 seconds.
|
|
392
|
-
lastPingTime: now, // π Track when last ping cycle started
|
|
393
|
-
lastUserActivityAt: now, // π Any keypress refreshes this timer; inactivity can force slow mode.
|
|
394
|
-
resumeSpeedOnActivity: false, // π Set after idle slowdown so the next activity restarts a 60s speed burst.
|
|
395
|
-
startupLatestVersion: latestVersion, // π Startup auto-check result reused by the footer banner after "skip update".
|
|
396
|
-
versionAlertsEnabled: !isDevMode, // π Dev checkouts should not tell contributors to upgrade the global npm package.
|
|
397
|
-
mode, // π 'opencode' or 'openclaw' β controls Enter action
|
|
398
|
-
tierFilterMode: 0, // π Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
|
|
399
|
-
originFilterMode: 0, // π Index into ORIGIN_CYCLE (0=All, then providers)
|
|
400
|
-
premiumMode: cliArgs.premiumMode, // π Special elite-only mode: S/S+ only, Health UP only, Perfect/Normal/Slow verdict only.
|
|
401
|
-
hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // π Hide providers with no configured API key when true.
|
|
402
|
-
disableWidthsWarning: config.settings?.disableWidthsWarning ?? false, // π Cached for runtime checks; keep it in sync with config.settings.
|
|
403
|
-
scrollOffset: 0, // π First visible model index in viewport
|
|
404
|
-
terminalRows: process.stdout.rows || 24, // π Current terminal height
|
|
405
|
-
terminalCols: process.stdout.columns || 80, // π Current terminal width
|
|
406
|
-
widthWarningStartedAt: (process.stdout.columns || 80) < 166 && !(config.settings?.disableWidthsWarning ?? false) ? now : null, // π Start immediately only when warnings are enabled in a narrow viewport.
|
|
407
|
-
widthWarningDismissed: false, // π Esc hides the narrow-terminal warning early for the current narrow-width session.
|
|
408
|
-
widthWarningShowCount: 0, // π Counter for how many times the narrow-terminal warning has been shown (max 2 per session).
|
|
409
|
-
// π Settings screen state (P key opens it)
|
|
410
|
-
settingsOpen: false, // π Whether settings overlay is active
|
|
411
|
-
settingsCursor: 0, // π Which provider row is selected in settings
|
|
412
|
-
settingsEditMode: false, // π Whether we're in inline key editing mode (edit primary key)
|
|
413
|
-
settingsAddKeyMode: false, // π Whether we're in add-key mode (append a new key to provider)
|
|
414
|
-
settingsEditBuffer: '', // π Typed characters for the API key being edited
|
|
415
|
-
settingsErrorMsg: null, // π Temporary error message to display in settings
|
|
416
|
-
settingsTestResults: {}, // π { providerKey: 'pending'|'ok'|'auth_error'|'rate_limited'|'no_callable_model'|'fail'|'missing_key'|null }
|
|
417
|
-
settingsTestDetails: {}, // π Long-form diagnostics shown under Setup Instructions after a Settings key test.
|
|
418
|
-
settingsUpdateState: 'idle', // π 'idle'|'checking'|'available'|'up-to-date'|'error'|'installing'
|
|
419
|
-
settingsUpdateLatestVersion: null, // π Latest npm version discovered from manual check
|
|
420
|
-
settingsUpdateError: null, // π Last update-check error message for maintenance row
|
|
421
|
-
config, // π Live reference to the config object (updated on save)
|
|
422
|
-
visibleSorted: [], // π Cached visible+sorted models β shared between render loop and key handlers
|
|
423
|
-
helpVisible: false, // π Whether the help overlay (K key) is active
|
|
424
|
-
settingsScrollOffset: 0, // π Vertical scroll offset for Settings overlay viewport
|
|
425
|
-
helpScrollOffset: 0, // π Vertical scroll offset for Help overlay viewport
|
|
426
|
-
// π Install Endpoints overlay state (Y key opens it)
|
|
427
|
-
installEndpointsOpen: false, // π Whether the install-endpoints overlay is active
|
|
428
|
-
installEndpointsPhase: 'providers', // π providers | tools | scope | models | result
|
|
429
|
-
installEndpointsCursor: 0, // π Selected row within the current install phase
|
|
430
|
-
installEndpointsScrollOffset: 0, // π Vertical scroll offset for the install overlay viewport
|
|
431
|
-
installEndpointsProviderKey: null, // π Selected provider for endpoint installation
|
|
432
|
-
installEndpointsToolMode: null, // π Selected target tool mode
|
|
433
|
-
installEndpointsConnectionMode: null, // π Direct provider path retained for future install flow state.
|
|
434
|
-
installEndpointsScope: null, // π all | selected
|
|
435
|
-
installEndpointsSelectedModelIds: new Set(), // π Multi-select buffer for the selected-models phase
|
|
436
|
-
installEndpointsErrorMsg: null, // π Temporary validation/error message inside the install flow
|
|
437
|
-
installEndpointsResult: null, // π Final install result shown in the result phase
|
|
438
|
-
// π Smart Recommend overlay state (Q key opens it)
|
|
439
|
-
recommendOpen: false, // π Whether the recommend overlay is active
|
|
440
|
-
recommendPhase: 'questionnaire', // π 'questionnaire'|'analyzing'|'results' β current phase
|
|
441
|
-
recommendCursor: 0, // π Selected question option (0-based index within current question)
|
|
442
|
-
recommendQuestion: 0, // π Which question we're on (0=task, 1=priority, 2=context)
|
|
443
|
-
recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // π User's answers
|
|
444
|
-
recommendProgress: 0, // π Analysis progress percentage (0β100)
|
|
445
|
-
recommendResults: [], // π Top N recommendations from getTopRecommendations()
|
|
446
|
-
recommendScrollOffset: 0, // π Vertical scroll offset for Recommend overlay viewport
|
|
447
|
-
recommendAnalysisTimer: null, // π setInterval handle for the 10s analysis phase
|
|
448
|
-
recommendPingTimer: null, // π setInterval handle for 2 pings/sec during analysis
|
|
449
|
-
recommendedKeys: new Set(), // π Set of "providerKey/modelId" for recommended models (shown in main table)
|
|
450
|
-
// π Feedback state (J/I keys open it)
|
|
451
|
-
feedbackOpen: false, // π Whether the feedback overlay is active
|
|
452
|
-
bugReportBuffer: '', // π Typed characters for the feedback message
|
|
453
|
-
bugReportStatus: 'idle', // π 'idle'|'sending'|'success'|'error' β webhook send status
|
|
454
|
-
bugReportError: null, // π Last webhook error message
|
|
455
|
-
// π OpenCode sync status (S key in settings)
|
|
456
|
-
settingsSyncStatus: null, // π { type: 'success'|'error', msg: string } β shown in settings footer
|
|
457
|
-
// π Changelog overlay state (N key opens it)
|
|
458
|
-
changelogOpen: false, // π Whether the changelog overlay is active
|
|
459
|
-
changelogScrollOffset: 0, // π Vertical scroll offset for changelog overlay viewport
|
|
460
|
-
changelogPhase: 'index', // π 'index' (all versions) | 'details' (specific version)
|
|
461
|
-
changelogCursor: 0, // π Selected row in index phase
|
|
462
|
-
changelogSelectedVersion: null, // π Which version to show details for
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// π Re-clamp viewport on terminal resize
|
|
466
|
-
process.stdout.on('resize', () => {
|
|
467
|
-
const prevCols = state.terminalCols
|
|
468
|
-
const widthsWarningDisabled = state.config.settings?.disableWidthsWarning === true
|
|
469
|
-
state.terminalRows = process.stdout.rows || 24
|
|
470
|
-
state.terminalCols = process.stdout.columns || 80
|
|
471
|
-
state.disableWidthsWarning = widthsWarningDisabled
|
|
472
|
-
if (state.terminalCols < 166 && !widthsWarningDisabled) {
|
|
473
|
-
if (prevCols >= 166 || state.widthWarningDismissed) {
|
|
474
|
-
state.widthWarningStartedAt = Date.now()
|
|
475
|
-
state.widthWarningDismissed = false
|
|
476
|
-
state.widthWarningShowCount++ // π Increment counter when showing the warning again
|
|
477
|
-
} else if (!state.widthWarningStartedAt) {
|
|
478
|
-
state.widthWarningStartedAt = Date.now()
|
|
479
|
-
}
|
|
480
|
-
} else {
|
|
481
|
-
state.widthWarningStartedAt = null
|
|
482
|
-
state.widthWarningDismissed = false
|
|
483
|
-
}
|
|
484
|
-
adjustScrollOffset(state)
|
|
485
|
-
})
|
|
486
|
-
|
|
487
|
-
let ticker = null
|
|
488
|
-
let onKeyPress = null
|
|
489
|
-
let pingModel = null
|
|
490
|
-
|
|
491
|
-
const scheduleNextPing = () => {
|
|
492
|
-
clearTimeout(state.pingIntervalObj)
|
|
493
|
-
const elapsed = Date.now() - state.lastPingTime
|
|
494
|
-
const delay = Math.max(0, state.pingInterval - elapsed)
|
|
495
|
-
state.pingIntervalObj = setTimeout(runPingCycle, delay)
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
const setPingMode = (nextMode, source = 'manual') => {
|
|
499
|
-
const modeInterval = PING_MODE_INTERVALS[nextMode] ?? PING_MODE_INTERVALS.normal
|
|
500
|
-
state.pingMode = nextMode
|
|
501
|
-
state.pingModeSource = source
|
|
502
|
-
state.pingInterval = modeInterval
|
|
503
|
-
state.speedModeUntil = nextMode === 'speed' ? Date.now() + SPEED_MODE_DURATION_MS : null
|
|
504
|
-
state.resumeSpeedOnActivity = source === 'idle'
|
|
505
|
-
if (state.pingIntervalObj) scheduleNextPing()
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const noteUserActivity = () => {
|
|
509
|
-
state.lastUserActivityAt = Date.now()
|
|
510
|
-
if (state.pingMode === 'forced') return
|
|
511
|
-
if (state.resumeSpeedOnActivity) {
|
|
512
|
-
setPingMode('speed', 'activity')
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const refreshAutoPingMode = () => {
|
|
517
|
-
const currentTime = Date.now()
|
|
518
|
-
if (state.pingMode === 'forced') return
|
|
519
|
-
|
|
520
|
-
if (state.speedModeUntil && currentTime >= state.speedModeUntil) {
|
|
521
|
-
setPingMode('normal', 'auto')
|
|
522
|
-
return
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
if (currentTime - state.lastUserActivityAt >= IDLE_SLOW_AFTER_MS) {
|
|
526
|
-
if (state.pingMode !== 'slow' || state.pingModeSource !== 'idle') {
|
|
527
|
-
setPingMode('slow', 'idle')
|
|
528
|
-
} else {
|
|
529
|
-
state.resumeSpeedOnActivity = true
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// π Load cache if available (for faster startup with cached ping results)
|
|
535
|
-
const cached = loadCache()
|
|
536
|
-
if (cached && cached.models) {
|
|
537
|
-
// π Apply cached values to results
|
|
538
|
-
for (const r of state.results) {
|
|
539
|
-
const cachedModel = cached.models[r.modelId]
|
|
540
|
-
if (cachedModel) {
|
|
541
|
-
r.avg = cachedModel.avg
|
|
542
|
-
r.p95 = cachedModel.p95
|
|
543
|
-
r.jitter = cachedModel.jitter
|
|
544
|
-
r.stability = cachedModel.stability
|
|
545
|
-
r.uptime = cachedModel.uptime
|
|
546
|
-
r.verdict = cachedModel.verdict
|
|
547
|
-
r.status = cachedModel.status
|
|
548
|
-
r.httpCode = cachedModel.httpCode
|
|
549
|
-
r.pings = cachedModel.pings || []
|
|
550
|
-
}
|
|
551
|
-
}
|
|
45
|
+
console.error(chalk.red(` Unknown tier "${cliArgs.tierFilter}". Valid tiers: S, A, B, C`));
|
|
46
|
+
process.exit(1);
|
|
552
47
|
}
|
|
553
48
|
|
|
554
|
-
// π
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
r.isPinging = true
|
|
559
|
-
|
|
560
|
-
try {
|
|
561
|
-
const providerApiKey = getApiKey(state.config, r.providerKey) ?? null
|
|
562
|
-
const providerUrl = sources[r.providerKey]?.url ?? sources.nvidia.url
|
|
563
|
-
let { code, ms, quotaPercent } = await ping(providerApiKey, r.modelId, r.providerKey, providerUrl)
|
|
564
|
-
|
|
565
|
-
if ((quotaPercent === null || quotaPercent === undefined) && providerApiKey) {
|
|
566
|
-
const providerQuota = await getProviderQuotaPercentCached(r.providerKey, providerApiKey)
|
|
567
|
-
if (typeof providerQuota === 'number' && Number.isFinite(providerQuota)) {
|
|
568
|
-
quotaPercent = providerQuota
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
r.pings.push({ ms, code })
|
|
573
|
-
|
|
574
|
-
if (code === '200') {
|
|
575
|
-
r.status = 'up'
|
|
576
|
-
} else if (code === '000') {
|
|
577
|
-
r.status = 'timeout'
|
|
578
|
-
} else if (code === '401' || code === '403') {
|
|
579
|
-
r.status = providerApiKey ? 'auth_error' : 'noauth'
|
|
580
|
-
r.httpCode = code
|
|
581
|
-
} else {
|
|
582
|
-
r.status = 'down'
|
|
583
|
-
r.httpCode = code
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
if (typeof quotaPercent === 'number' && Number.isFinite(quotaPercent)) {
|
|
587
|
-
r.usagePercent = quotaPercent
|
|
588
|
-
for (const sibling of state.results) {
|
|
589
|
-
if (sibling.providerKey === r.providerKey && (sibling.usagePercent === undefined || sibling.usagePercent === null)) {
|
|
590
|
-
sibling.usagePercent = quotaPercent
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
} finally {
|
|
595
|
-
r.isPinging = false
|
|
596
|
-
state.pendingPings = Math.max(0, state.pendingPings - 1)
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
// π JSON output mode: skip TUI, output results as JSON after initial pings
|
|
601
|
-
if (cliArgs.jsonMode) {
|
|
602
|
-
console.log(chalk.cyan(' β‘ Pinging models for JSON output...'))
|
|
603
|
-
console.log()
|
|
604
|
-
|
|
605
|
-
// π Run initial pings
|
|
606
|
-
const initialPing = Promise.all(state.results.map(r => pingModel(r)))
|
|
607
|
-
await initialPing
|
|
608
|
-
|
|
609
|
-
// π Calculate final stats
|
|
610
|
-
state.results.forEach(r => {
|
|
611
|
-
r.avg = getAvg(r)
|
|
612
|
-
r.p95 = getP95(r)
|
|
613
|
-
r.jitter = getJitter(r)
|
|
614
|
-
r.stability = getStabilityScore(r)
|
|
615
|
-
r.uptime = getUptime(r)
|
|
616
|
-
r.verdict = getVerdict(r)
|
|
617
|
-
})
|
|
618
|
-
|
|
619
|
-
// π Apply tier filter if specified
|
|
620
|
-
let outputResults = state.results
|
|
621
|
-
if (cliArgs.tierFilter) {
|
|
622
|
-
const filteredTier = TIER_LETTER_MAP[cliArgs.tierFilter]
|
|
623
|
-
if (filteredTier) {
|
|
624
|
-
outputResults = state.results.filter(r => filteredTier.includes(r.tier))
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// π Apply best mode filter if specified
|
|
629
|
-
if (cliArgs.bestMode) {
|
|
630
|
-
outputResults = outputResults.filter(r => ['S+', 'S', 'A+'].includes(r.tier))
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
// π Apply premium mode filter if specified: elite-only (S/S+, UP, Good Verdict)
|
|
634
|
-
if (cliArgs.premiumMode) {
|
|
635
|
-
outputResults = outputResults.filter(r => {
|
|
636
|
-
const isEliteTier = r.tier === 'S' || r.tier === 'S+'
|
|
637
|
-
const isHealthUp = r.status === 'up'
|
|
638
|
-
const verdict = getVerdict(r)
|
|
639
|
-
const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
|
|
640
|
-
return isEliteTier && isHealthUp && isGoodVerdict
|
|
641
|
-
})
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
// π Sort by avg ping (ascending)
|
|
645
|
-
outputResults = sortResults(outputResults, 'avg', 'asc')
|
|
646
|
-
|
|
647
|
-
// π Output JSON
|
|
648
|
-
console.log(formatResultsAsJSON(outputResults))
|
|
649
|
-
|
|
650
|
-
// π Save cache before exiting
|
|
651
|
-
saveCache(state.results, state.pingMode)
|
|
652
|
-
|
|
653
|
-
process.exit(0)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
// π Enter alternate screen β animation runs here, zero scrollback pollution
|
|
657
|
-
process.stdout.write(ALT_ENTER)
|
|
658
|
-
if (process.stdout.isTTY) {
|
|
659
|
-
process.stdout.flush && process.stdout.flush()
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// π Ensure we always leave alt screen cleanly (Ctrl+C, crash, normal exit)
|
|
663
|
-
const exit = (code = 0) => {
|
|
664
|
-
// π Save cache before exiting so next run starts faster
|
|
665
|
-
saveCache(state.results, state.pingMode)
|
|
666
|
-
clearInterval(ticker)
|
|
667
|
-
clearTimeout(state.pingIntervalObj)
|
|
668
|
-
process.stdout.write(ALT_LEAVE)
|
|
669
|
-
if (process.stdout.isTTY) {
|
|
670
|
-
process.stdout.flush && process.stdout.flush()
|
|
671
|
-
}
|
|
672
|
-
process.exit(code)
|
|
673
|
-
}
|
|
674
|
-
process.on('SIGINT', () => exit(0))
|
|
675
|
-
process.on('SIGTERM', () => exit(0))
|
|
676
|
-
|
|
677
|
-
// π originFilterMode: index into ORIGIN_CYCLE, 0=All, then each provider key in order
|
|
678
|
-
const ORIGIN_CYCLE = [null, ...Object.keys(sources)]
|
|
679
|
-
const resolvedTierFilter = config.settings?.tierFilter
|
|
680
|
-
state.tierFilterMode = resolvedTierFilter ? Math.max(0, TIER_CYCLE.indexOf(resolvedTierFilter)) : 0
|
|
681
|
-
const resolvedOriginFilter = config.settings?.originFilter
|
|
682
|
-
state.originFilterMode = resolvedOriginFilter ? Math.max(0, ORIGIN_CYCLE.indexOf(resolvedOriginFilter)) : 0
|
|
683
|
-
|
|
684
|
-
function applyTierFilter() {
|
|
685
|
-
const activeTier = TIER_CYCLE[state.tierFilterMode]
|
|
686
|
-
const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
|
|
687
|
-
state.results.forEach(r => {
|
|
688
|
-
// π Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
|
|
689
|
-
if (r.isFavorite) {
|
|
690
|
-
r.hidden = false
|
|
691
|
-
return
|
|
692
|
-
}
|
|
693
|
-
const unconfiguredHide = state.hideUnconfiguredModels && !getApiKey(state.config, r.providerKey)
|
|
694
|
-
if (unconfiguredHide) {
|
|
695
|
-
r.hidden = true
|
|
696
|
-
return
|
|
697
|
-
}
|
|
698
|
-
// π Apply both tier and origin filters β model is hidden if it fails either
|
|
699
|
-
// π TIER_LETTER_MAP is used so --tier S also includes S+ models (tier family behavior).
|
|
700
|
-
const allowedTiers = (activeTier && TIER_LETTER_MAP[activeTier]) ? TIER_LETTER_MAP[activeTier] : [activeTier]
|
|
701
|
-
const tierHide = activeTier !== null && !allowedTiers.includes(r.tier)
|
|
702
|
-
const originHide = activeOrigin !== null && r.providerKey !== activeOrigin
|
|
703
|
-
r.hidden = tierHide || originHide
|
|
704
|
-
|
|
705
|
-
// π Premium Mode: elite-only constraints (Health UP, Good Verdict, S/S+ only)
|
|
706
|
-
if (state.premiumMode && !r.hidden) {
|
|
707
|
-
const isEliteTier = r.tier === 'S' || r.tier === 'S+'
|
|
708
|
-
const isHealthUp = r.status === 'up'
|
|
709
|
-
const verdict = getVerdict(r)
|
|
710
|
-
const isGoodVerdict = ['Perfect', 'Normal', 'Slow'].includes(verdict)
|
|
711
|
-
|
|
712
|
-
if (!isEliteTier || !isHealthUp || !isGoodVerdict) {
|
|
713
|
-
r.hidden = true
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
})
|
|
717
|
-
return state.results
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
// π Apply initial filters so configured-only mode works on first render
|
|
721
|
-
applyTierFilter()
|
|
722
|
-
|
|
723
|
-
// βββ Overlay renderers + key handler βββββββββββββββββββββββββββββββββββββ
|
|
724
|
-
const stopUi = ({ resetRawMode = false } = {}) => {
|
|
725
|
-
if (ticker) clearInterval(ticker)
|
|
726
|
-
clearTimeout(state.pingIntervalObj)
|
|
727
|
-
if (onKeyPress) process.stdin.removeListener('keypress', onKeyPress)
|
|
728
|
-
if (process.stdin.isTTY && resetRawMode) process.stdin.setRawMode(false)
|
|
729
|
-
process.stdin.pause()
|
|
730
|
-
process.stdout.write(ALT_LEAVE)
|
|
731
|
-
if (process.stdout.isTTY) {
|
|
732
|
-
process.stdout.flush && process.stdout.flush()
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
const overlays = createOverlayRenderers(state, {
|
|
737
|
-
chalk,
|
|
738
|
-
sources,
|
|
739
|
-
PROVIDER_METADATA,
|
|
740
|
-
PROVIDER_COLOR,
|
|
741
|
-
LOCAL_VERSION,
|
|
742
|
-
getApiKey,
|
|
743
|
-
resolveApiKeys,
|
|
744
|
-
isProviderEnabled,
|
|
745
|
-
TIER_CYCLE,
|
|
746
|
-
SETTINGS_OVERLAY_BG,
|
|
747
|
-
HELP_OVERLAY_BG,
|
|
748
|
-
RECOMMEND_OVERLAY_BG,
|
|
749
|
-
OVERLAY_PANEL_WIDTH,
|
|
750
|
-
keepOverlayTargetVisible,
|
|
751
|
-
sliceOverlayLines,
|
|
752
|
-
tintOverlayLines,
|
|
753
|
-
TASK_TYPES,
|
|
754
|
-
PRIORITY_TYPES,
|
|
755
|
-
CONTEXT_BUDGETS,
|
|
756
|
-
FRAMES,
|
|
757
|
-
TIER_COLOR,
|
|
758
|
-
getAvg,
|
|
759
|
-
getStabilityScore,
|
|
760
|
-
toFavoriteKey,
|
|
761
|
-
getTopRecommendations,
|
|
762
|
-
adjustScrollOffset,
|
|
763
|
-
getPingModel: () => pingModel,
|
|
764
|
-
getConfiguredInstallableProviders,
|
|
765
|
-
getInstallTargetModes,
|
|
766
|
-
getProviderCatalogModels,
|
|
767
|
-
getToolMeta,
|
|
768
|
-
})
|
|
769
|
-
|
|
770
|
-
onKeyPress = createKeyHandler({
|
|
771
|
-
state,
|
|
772
|
-
exit,
|
|
773
|
-
cliArgs,
|
|
774
|
-
MODELS,
|
|
775
|
-
sources,
|
|
776
|
-
getApiKey,
|
|
777
|
-
resolveApiKeys,
|
|
778
|
-
addApiKey,
|
|
779
|
-
removeApiKey,
|
|
780
|
-
persistApiKeysForProvider,
|
|
781
|
-
isProviderEnabled,
|
|
782
|
-
saveConfig,
|
|
783
|
-
getConfiguredInstallableProviders,
|
|
784
|
-
getInstallTargetModes,
|
|
785
|
-
getProviderCatalogModels,
|
|
786
|
-
installProviderEndpoints,
|
|
787
|
-
syncFavoriteFlags,
|
|
788
|
-
toggleFavoriteModel,
|
|
789
|
-
sortResultsWithPinnedFavorites,
|
|
790
|
-
adjustScrollOffset,
|
|
791
|
-
applyTierFilter,
|
|
792
|
-
PING_INTERVAL,
|
|
793
|
-
TIER_CYCLE,
|
|
794
|
-
ORIGIN_CYCLE,
|
|
795
|
-
ENV_VAR_NAMES,
|
|
796
|
-
checkForUpdateDetailed,
|
|
797
|
-
runUpdate,
|
|
798
|
-
startOpenClaw,
|
|
799
|
-
startOpenCodeDesktop,
|
|
800
|
-
startOpenCode,
|
|
801
|
-
startExternalTool,
|
|
802
|
-
getToolModeOrder,
|
|
803
|
-
startRecommendAnalysis: overlays.startRecommendAnalysis,
|
|
804
|
-
stopRecommendAnalysis: overlays.stopRecommendAnalysis,
|
|
805
|
-
sendBugReport,
|
|
806
|
-
stopUi,
|
|
807
|
-
ping,
|
|
808
|
-
TASK_TYPES,
|
|
809
|
-
PRIORITY_TYPES,
|
|
810
|
-
CONTEXT_BUDGETS,
|
|
811
|
-
toFavoriteKey,
|
|
812
|
-
mergedModels,
|
|
813
|
-
chalk,
|
|
814
|
-
setPingMode,
|
|
815
|
-
noteUserActivity,
|
|
816
|
-
intervalToPingMode,
|
|
817
|
-
PING_MODE_CYCLE,
|
|
818
|
-
setResults: (next) => { results = next },
|
|
819
|
-
readline,
|
|
820
|
-
})
|
|
821
|
-
|
|
822
|
-
// Apply CLI --tier filter if provided
|
|
823
|
-
if (cliArgs.tierFilter) {
|
|
824
|
-
const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
|
|
825
|
-
state.results.forEach(r => {
|
|
826
|
-
r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
|
|
827
|
-
})
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// π Setup keyboard input for interactive selection during pings
|
|
831
|
-
// π Use readline with keypress event for arrow key handling
|
|
832
|
-
process.stdin.setEncoding('utf8')
|
|
833
|
-
process.stdin.resume()
|
|
834
|
-
|
|
835
|
-
let userSelected = null
|
|
836
|
-
|
|
837
|
-
// π Enable keypress events on stdin
|
|
838
|
-
readline.emitKeypressEvents(process.stdin)
|
|
839
|
-
if (process.stdin.isTTY) {
|
|
840
|
-
process.stdin.setRawMode(true)
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
process.stdin.on('keypress', onKeyPress)
|
|
844
|
-
process.on('SIGCONT', noteUserActivity)
|
|
845
|
-
|
|
846
|
-
// π Animation loop: render settings overlay, recommend overlay, help overlay, feature request overlay, bug report overlay, changelog overlay, OR main table
|
|
847
|
-
ticker = setInterval(() => {
|
|
848
|
-
refreshAutoPingMode()
|
|
849
|
-
state.frame++
|
|
850
|
-
// π Cache visible+sorted models each frame so Enter handler always matches the display
|
|
851
|
-
if (!state.settingsOpen && !state.installEndpointsOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen) {
|
|
852
|
-
const visible = state.results.filter(r => !r.hidden)
|
|
853
|
-
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
854
|
-
}
|
|
855
|
-
const content = state.settingsOpen
|
|
856
|
-
? overlays.renderSettings()
|
|
857
|
-
: state.installEndpointsOpen
|
|
858
|
-
? overlays.renderInstallEndpoints()
|
|
859
|
-
: state.recommendOpen
|
|
860
|
-
? overlays.renderRecommend()
|
|
861
|
-
: state.feedbackOpen
|
|
862
|
-
? overlays.renderFeedback()
|
|
863
|
-
: state.helpVisible
|
|
864
|
-
? overlays.renderHelp()
|
|
865
|
-
: state.changelogOpen
|
|
866
|
-
? overlays.renderChangelog()
|
|
867
|
-
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.config.settings?.disableWidthsWarning ?? false)
|
|
868
|
-
process.stdout.write(ALT_HOME + content)
|
|
869
|
-
if (process.stdout.isTTY) {
|
|
870
|
-
process.stdout.flush && process.stdout.flush()
|
|
871
|
-
}
|
|
872
|
-
}, Math.round(1000 / FPS))
|
|
873
|
-
|
|
874
|
-
// π Populate visibleSorted before the first frame so Enter works immediately
|
|
875
|
-
const initialVisible = state.results.filter(r => !r.hidden)
|
|
876
|
-
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
877
|
-
|
|
878
|
-
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.config.settings?.disableWidthsWarning ?? false))
|
|
879
|
-
if (process.stdout.isTTY) {
|
|
880
|
-
process.stdout.flush && process.stdout.flush()
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// π If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
884
|
-
if (cliArgs.recommendMode) {
|
|
885
|
-
state.recommendOpen = true
|
|
886
|
-
state.recommendPhase = 'questionnaire'
|
|
887
|
-
state.recommendCursor = 0
|
|
888
|
-
state.recommendQuestion = 0
|
|
889
|
-
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
890
|
-
state.recommendProgress = 0
|
|
891
|
-
state.recommendResults = []
|
|
892
|
-
state.recommendScrollOffset = 0
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
// ββ Continuous ping loop β ping all models every N seconds forever ββββββββββ
|
|
896
|
-
|
|
897
|
-
// π Initial ping of all models
|
|
898
|
-
const initialPing = Promise.all(state.results.map(r => pingModel(r)))
|
|
899
|
-
|
|
900
|
-
// π Continuous ping loop with mode-driven cadence.
|
|
901
|
-
const runPingCycle = async () => {
|
|
902
|
-
refreshAutoPingMode()
|
|
903
|
-
state.lastPingTime = Date.now()
|
|
904
|
-
|
|
905
|
-
// π Refresh persisted usage snapshots each cycle so background usage data appears live in table.
|
|
906
|
-
// π Freshness-aware: stale snapshots (>30m) are excluded and row reverts to undefined.
|
|
907
|
-
for (const r of state.results) {
|
|
908
|
-
const pct = _usageForRow(r.providerKey, r.modelId)
|
|
909
|
-
if (typeof pct === 'number' && Number.isFinite(pct)) {
|
|
910
|
-
r.usagePercent = pct
|
|
911
|
-
} else {
|
|
912
|
-
// If snapshot is now stale or gone, clear the cached value so UI shows N/A.
|
|
913
|
-
r.usagePercent = undefined
|
|
914
|
-
}
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
state.results.forEach(r => {
|
|
918
|
-
pingModel(r).catch(() => {
|
|
919
|
-
// Individual ping failures don't crash the loop
|
|
920
|
-
})
|
|
921
|
-
})
|
|
922
|
-
|
|
923
|
-
refreshAutoPingMode()
|
|
924
|
-
scheduleNextPing()
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// π Start the ping loop
|
|
928
|
-
state.pingIntervalObj = null
|
|
929
|
-
scheduleNextPing()
|
|
930
|
-
|
|
931
|
-
await initialPing
|
|
932
|
-
|
|
933
|
-
// π Save cache after initial pings complete for faster next startup
|
|
934
|
-
saveCache(state.results, state.pingMode)
|
|
49
|
+
// π Load JSON config
|
|
50
|
+
const config = loadConfig();
|
|
51
|
+
ensureTelemetryConfig(config);
|
|
52
|
+
ensureFavoritesConfig(config);
|
|
935
53
|
|
|
936
|
-
|
|
937
|
-
// π The pings continue running in background with dynamic interval
|
|
938
|
-
// π User can press W to decrease interval (faster pings) or = to increase (slower)
|
|
939
|
-
// π Current interval shown in header: "next ping Xs"
|
|
54
|
+
await runApp(cliArgs, config);
|
|
940
55
|
}
|
|
941
56
|
|
|
942
57
|
main().catch((err) => {
|
|
943
|
-
process.stdout.write(ALT_LEAVE)
|
|
944
|
-
console.error(
|
|
945
|
-
|
|
946
|
-
|
|
58
|
+
process.stdout.write(ALT_LEAVE);
|
|
59
|
+
console.error(chalk.red('\n[Fatal Error]'));
|
|
60
|
+
console.error(err);
|
|
61
|
+
console.error(chalk.yellow('\nPlease file an issue at https://github.com/vava-nessa/free-coding-models/issues or use the feedback form (I key) to report this to the author.'));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|