free-coding-models 0.3.80 → 0.4.1

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/README.md CHANGED
@@ -422,6 +422,8 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
422
422
  | `X` | Clear active custom text filter |
423
423
  | `G` | Cycle global theme (`Auto → Dark → Light`) |
424
424
  | `Ctrl+P` | Open ⚡️ command palette (search + run actions) |
425
+ | `Ctrl+A` | Run AI Speed Test for the selected model |
426
+ | `Ctrl+U` | Run Global AI Speed Test (uses real provider requests) |
425
427
  | `R/S/C/M/O/L/A/H/V/B/U` | Sort columns |
426
428
  | `Shift+U` | Update to latest version (when update available) |
427
429
  | `P` | Settings (API keys, providers, updates, theme) |
@@ -456,7 +458,8 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
456
458
  ## ✨ Features
457
459
 
458
460
  - **Parallel pings** — all ~165 API/Zen-callable models tested simultaneously via native `fetch` (~170 total cataloged models including CLI-only Gemini rows)
459
- - **AI benchmark columns** — `Ctrl+A` benchmarks the selected model, `Ctrl+U` benchmarks visible models, and results split cleanly into **AI Latency** plus **TPS**.
461
+ - **AI benchmark columns** — `Ctrl+A` benchmarks the selected model, `Ctrl+U` benchmarks visible models, and results split cleanly into **AI Latency** plus **TPS**. Settings includes an opt-in **Startup AI Speed Scan** toggle to run the global benchmark automatically after launch.
462
+ - **Tiny verdict indicator** — the first `❔` column mirrors the full Verdict as a compact emoji (`🟩`, `🟢`, `🟡`, `🟠`, etc.) and sorts by the same verdict order.
460
463
  - **Adaptive monitoring** — 2s burst for 60s → 10s normal → 30s idle
461
464
  - **Stability score** — composite 0–100 (p95 latency, jitter, spike rate, uptime)
462
465
  - **Smart ranking** — top 3 highlighted 🥇🥈🥉
@@ -0,0 +1,14 @@
1
+ # Changelog v0.3.81 - 2026-05-30
2
+
3
+ ### Added
4
+ - **Tiny verdict indicator column** — Added a first-column `❔` status indicator that shows the current verdict as a compact emoji while keeping the existing `Verdict` column intact for users who already rely on the full text labels.
5
+ - **Clickable verdict shortcut** — The new `❔` column uses the same verdict sort behavior as the existing `Verdict` column, so users can sort by model condition from the far-left edge of the table.
6
+ - **Benchmark retry with blue badge** — Ctrl+A and Ctrl+U now retry failed models up to 3 times with 15s delay. Successful retries show a blue `↻N` badge in AI Latency and TPS columns.
7
+ - **Smart benchmark ordering** — Ctrl+U now tests UP/healthy models first (low ping → high ping), then timeout/noauth/down models last. Fast results appear instantly instead of waiting for slow retries.
8
+ - **Health updates from benchmark** — When a benchmark succeeds or fails, the model's Health column updates in real-time (e.g. a 429 model that responds gets marked UP).
9
+ - **Raw byte fallback for Ctrl+U** — Added `\x15` fallback so Ctrl+U works in Ghostty and other terminals where readline swallows the key event.
10
+
11
+ ### Changed
12
+ - **Clearer verdict emojis** — Updated the TUI verdict icons to use the same compact visual language in both places: `🟩 Perfect`, `🟢 Normal`, `🟡 Spiky`, `🟠 Slow`, `🔴 Very Slow`, `🔥 Overloaded`, `⚠️ Unstable`, `⚫ Not Active`, and `⏳ Pending`.
13
+ - **Benchmark runs on ALL models** — Ctrl+U and Ctrl+A no longer skip models based on health status, TUI filters, or missing provider URLs. Every model gets tested.
14
+ - **Red dash on benchmark error** — Failed benchmarks show a red `—` in AI Latency and TPS columns instead of error codes (which already appear in the Health column).
@@ -0,0 +1,23 @@
1
+ # Changelog v0.4.0 - 2026-05-30
2
+
3
+ ### Added
4
+ - **Startup AI Speed Scan opt-in** — Added a new Settings toggle that can automatically run the global Ctrl+U AI Speed Test after each app launch. It is disabled by default because it uses real provider requests, and first-time onboarding now asks whether users want to enable it.
5
+ - **Tiny verdict indicator column** — Added a compact first-column `❔` verdict indicator so users can scan model condition instantly without reading the full `Verdict` column.
6
+ - **Clickable verdict shortcut** — The new compact verdict column sorts with the same logic as the full `Verdict` column, making the far-left status indicator a fast sorting target.
7
+ - **Benchmark retry badges** — Ctrl+A and Ctrl+U now retry failed benchmark requests up to 3 times with a 15s delay. Successful retries show a blue `↻N` badge beside AI Latency and TPS values so users know the result recovered after transient provider trouble.
8
+ - **Smart global benchmark ordering** — Ctrl+U now tests healthy/UP models first, sorted by low ping, then slower or problematic models afterward. This gives useful benchmark results quickly instead of letting timeouts dominate the start of the run.
9
+ - **Benchmark-driven health updates** — Real AI Speed Test results now update the Health column live. A model that succeeds during benchmarking can recover from stale timeout/down states, while auth/rate-limit/errors are reflected immediately.
10
+ - **Ctrl+U raw-byte fallback** — Added the `\x15` fallback so Global AI Speed Test works in terminals where Node/readline does not report Ctrl+U normally.
11
+ - **Header flash feedback** — Column header clicks now flash the active column, making mouse sorting easier to confirm visually.
12
+
13
+ ### Changed
14
+ - **v0.4.0 supersedes the broken v0.3.81 bump** — This release consolidates all post-v0.3.80 benchmark, verdict, and startup-scan changes into a clean minor release line.
15
+ - **AI Speed Test now benchmarks every model** — Ctrl+A and Ctrl+U no longer skip models just because they are filtered, unhealthy, missing a provider URL, or currently showing an error state. The benchmark path now attempts the real request and lets the result decide.
16
+ - **Benchmark errors are visually cleaner** — Failed benchmarks show a red `—` in AI Latency and TPS instead of duplicating HTTP/error labels in the benchmark columns. Detailed status remains in the Health column.
17
+ - **Clearer verdict emoji language** — Verdict visuals now use a consistent compact mapping: `🟩 Perfect`, `🟢 Normal`, `🟡 Spiky`, `🟠 Slow`, `🔴 Very Slow`, `🔥 Overloaded`, `⚠️ Unstable`, `⚫ Not Active`, and `⏳ Pending`.
18
+ - **Settings and docs mention Global AI Speed Test clearly** — README, Help, and Settings now explain Ctrl+A, Ctrl+U, and the optional startup auto-run behavior more explicitly.
19
+
20
+ ### Fixed
21
+ - **Ctrl+A works on error rows** — The selected-model benchmark can run even when the row currently has an error/timeout/no-key status, so users can retest problematic models directly.
22
+ - **Global benchmark ignores UI filters correctly** — Ctrl+U benchmarks the complete model set instead of accidentally obeying active filters or visible-table state.
23
+ - **Benchmark result display is stable** — Benchmark results now render consistently regardless of Health state, avoiding cases where successful benchmark data was hidden behind stale row status.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.80",
3
+ "version": "0.4.1",
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",
package/src/app.js CHANGED
@@ -337,7 +337,7 @@ export async function runApp(cliArgs, config) {
337
337
  status: 'pending',
338
338
  pings: [], // 📖 All ping results (ms or 'TIMEOUT')
339
339
  httpCode: null,
340
- isPinging: false, // 📖 Per-row live flag so Latest Ping can keep last value and show a spinner during refresh.
340
+ isPinging: false, // 📖 Per-row live flag so Last Ping can keep last value and show a spinner during refresh.
341
341
  hidden: false, // 📖 Simple flag to hide/show models
342
342
  }))
343
343
  syncFavoriteFlags(results, config)
@@ -785,6 +785,7 @@ export async function runApp(cliArgs, config) {
785
785
  const visible = state.results.filter(r => !r.hidden)
786
786
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
787
787
  pinFavorites: state.favoritesPinnedAndSticky,
788
+ benchmarkResults: state.benchmarkResults,
788
789
  })
789
790
  if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
790
791
  adjustScrollOffset(state)
@@ -792,6 +793,11 @@ export async function runApp(cliArgs, config) {
792
793
  const tableTerminalRows = state.terminalRows
793
794
 
794
795
  let tableContent = null
796
+ // 📖 Clear expired header flash animation
797
+ if (state.headerFlashColumn && state.frame >= state.headerFlashUntilFrame) {
798
+ state.headerFlashColumn = null
799
+ }
800
+
795
801
  // 📖 Build renderTable options once per frame — keeps all call sites in sync
796
802
  const tableOpts = {
797
803
  results: state.results,
@@ -825,6 +831,7 @@ export async function runApp(cliArgs, config) {
825
831
  bestModeOnly: state.bestModeOnly,
826
832
  benchmarkResults: state.benchmarkResults,
827
833
  benchmarkRunning: state.benchmarkRunning,
834
+ headerFlashColumn: state.headerFlashColumn,
828
835
  }
829
836
  if (state.commandPaletteOpen) {
830
837
  if (!state.commandPaletteFrozenTable) {
@@ -880,6 +887,7 @@ export async function runApp(cliArgs, config) {
880
887
  const initialVisible = state.results.filter(r => !r.hidden)
881
888
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection, {
882
889
  pinFavorites: state.favoritesPinnedAndSticky,
890
+ benchmarkResults: state.benchmarkResults,
883
891
  })
884
892
 
885
893
  process.stdout.write(ALT_HOME + renderTable({
@@ -931,6 +939,17 @@ export async function runApp(cliArgs, config) {
931
939
  state.recommendScrollOffset = 0
932
940
  }
933
941
 
942
+ // 📖 Startup AI Speed Scan: opt-in setting that reuses the Ctrl+U path so the
943
+ // 📖 automatic launch stays identical to the manual global benchmark behavior.
944
+ // 📖 1.5s delay ensures the TUI has rendered at least one full frame and the
945
+ // 📖 keypress handler pipeline is live before we simulate Ctrl+U.
946
+ const scheduleAiSpeedScanOnStartup = () => {
947
+ if (state.config.settings?.runAiSpeedTestOnStartup !== true) return
948
+ setTimeout(() => {
949
+ onKeyPress?.('\x15', { name: 'u', ctrl: true, meta: false, shift: false })?.catch(() => {})
950
+ }, 1500)
951
+ }
952
+
934
953
  // ── Continuous ping loop — ping all models every N seconds forever ──────────
935
954
 
936
955
  // 📖 Initial ping of all models
@@ -992,6 +1011,7 @@ export async function runApp(cliArgs, config) {
992
1011
  scheduleNextPing()
993
1012
 
994
1013
  await initialPing
1014
+ scheduleAiSpeedScanOnStartup()
995
1015
 
996
1016
  // 📖 Save cache after initial pings complete for faster next startup
997
1017
  saveCache(state.results, state.pingMode)
package/src/benchmark.js CHANGED
@@ -43,9 +43,16 @@ export const BENCHMARK_MAX_TOKENS = 140
43
43
  // 📖 BENCHMARK_TEMPERATURE: Zero temperature for deterministic, reproducible results.
44
44
  export const BENCHMARK_TEMPERATURE = 0
45
45
 
46
- // 📖 BENCHMARK_TIMEOUT_MS: How long to wait before treating a benchmark as failed.
46
+ // 📖 BENCHMARK_TIMEOUT_MS: How long to wait before treating a benchmark attempt as timed out.
47
47
  export const BENCHMARK_TIMEOUT_MS = 20_000
48
48
 
49
+ // 📖 BENCHMARK_MAX_RETRIES: Number of attempts before giving up. Models that are timeout,
50
+ // 📖 429, or temporarily down may succeed on a later attempt — this is the whole point.
51
+ export const BENCHMARK_MAX_RETRIES = 3
52
+
53
+ // 📖 BENCHMARK_RETRY_DELAY_MS: Wait time between failed attempts so the server can recover.
54
+ export const BENCHMARK_RETRY_DELAY_MS = 15_000
55
+
49
56
  // 📖 estimateTokensFromText: Fallback token counter when the API does not return usage.
50
57
  // 📖 Uses a simple heuristic: avg English token ≈ 4 chars. This is explicitly an ESTIMATE
51
58
  // 📖 and is labeled as such everywhere it surfaces. Do not use for billing.
@@ -60,26 +67,33 @@ function benchmarkSpinner(frame) {
60
67
  return ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'][spinIdx]
61
68
  }
62
69
 
63
- // 📖 formatBenchmarkLatency: Turn a raw benchmark result into the AI Latency column value.
64
- // 📖 Success: "4.3s" / "12s". Error: compact error code. Empty: "—".
70
+ // 📖 retryBadge: compact retry indicator appended to latency when > 0 attempts failed.
71
+ function retryBadge(retries) {
72
+ return (typeof retries === 'number' && retries > 0) ? `↻${retries}` : ''
73
+ }
74
+
75
+ // 📖 formatBenchmarkLatency: Returns { text, retryBadge } so the renderer can color
76
+ // 📖 the retry badge independently (blue) vs the latency value (green).
65
77
  export function formatBenchmarkLatency(result, { running = false, frame = 0 } = {}) {
66
- if (running) return benchmarkSpinner(frame)
67
- if (!result) return '—'
68
- if (!result.ok) return result.code || 'ERR'
78
+ if (running) return { text: benchmarkSpinner(frame), retryBadge: '' }
79
+ if (!result) return { text: '—', retryBadge: '' }
80
+ if (!result.ok) return { text: result.code || 'ERR', retryBadge: '' }
69
81
 
70
82
  const totalSeconds = result.totalMs / 1000
71
- return totalSeconds >= 10
83
+ const badge = retryBadge(result.retries)
84
+ const latency = totalSeconds >= 10
72
85
  ? totalSeconds.toFixed(0) + 's'
73
86
  : totalSeconds.toFixed(1) + 's'
87
+ return { text: latency, retryBadge: badge }
74
88
  }
75
89
 
76
- // 📖 formatBenchmarkTps: Turn a raw benchmark result into the TPS column value.
77
- // 📖 Success is the rounded tokens/second number only because the header carries "TPS".
78
- // 📖 Errors and empty state stay as a dim dash in the table to avoid duplicating codes.
90
+ // 📖 formatBenchmarkTps: Returns { text, retryBadge } so the renderer can color
91
+ // 📖 the retry badge independently (blue) vs the TPS value (green).
79
92
  export function formatBenchmarkTps(result, { running = false, frame = 0 } = {}) {
80
- if (running) return benchmarkSpinner(frame)
81
- if (!result || !result.ok) return '—'
82
- return String(Math.round(result.tokensPerSecond ?? 0))
93
+ if (running) return { text: benchmarkSpinner(frame), retryBadge: '' }
94
+ if (!result || !result.ok) return { text: '—', retryBadge: '' }
95
+ const badge = retryBadge(result.retries)
96
+ return { text: String(Math.round(result.tokensPerSecond ?? 0)), retryBadge: badge }
83
97
  }
84
98
 
85
99
  // 📖 formatBenchmarkResult: legacy combined formatter retained for integrations/tests
@@ -88,7 +102,9 @@ export function formatBenchmarkResult(result, options = {}) {
88
102
  if (options.running) return benchmarkSpinner(options.frame ?? 0)
89
103
  if (!result) return '—'
90
104
  if (!result.ok) return result.code || 'ERR'
91
- return `${formatBenchmarkLatency(result)} / ${formatBenchmarkTps(result)} TPS`
105
+ const lat = formatBenchmarkLatency(result)
106
+ const tps = formatBenchmarkTps(result)
107
+ return `${lat.text}${lat.retryBadge} / ${tps.text}${tps.retryBadge} TPS`
92
108
  }
93
109
 
94
110
  // 📖 buildBenchmarkRequest: Build provider-specific benchmark request.
@@ -160,17 +176,8 @@ export function buildBenchmarkRequest(apiKey, modelId, providerKey, url) {
160
176
  // 📖 totalMs: 15000,
161
177
  // 📖 error: "Request timed out"
162
178
  // 📖 }
163
- export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeoutMs = BENCHMARK_TIMEOUT_MS }) {
164
- // 📖 Guard: unsupported providers that don't do chat completions
165
- if (providerKey === 'rovo' || providerKey === 'gemini' || providerKey === 'opencode-zen') {
166
- return {
167
- ok: false,
168
- code: 'UNSUPPORTED',
169
- totalMs: 0,
170
- error: 'Provider does not support chat completions',
171
- }
172
- }
173
-
179
+ // 📖 benchmarkSingleAttempt: One HTTP attempt. Extracted so the retry loop stays clean.
180
+ async function benchmarkSingleAttempt({ apiKey, modelId, providerKey, url, timeoutMs }) {
174
181
  const ctrl = new AbortController()
175
182
  const timer = setTimeout(() => ctrl.abort(), timeoutMs)
176
183
  const t0 = performance.now()
@@ -188,14 +195,9 @@ export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeou
188
195
 
189
196
  // 📖 Parse response body regardless of HTTP status so we can extract partial data
190
197
  let bodyText = ''
191
- try {
192
- bodyText = await resp.text()
193
- } catch {}
194
-
198
+ try { bodyText = await resp.text() } catch {}
195
199
  let data = null
196
- try {
197
- data = JSON.parse(bodyText)
198
- } catch {}
200
+ try { data = JSON.parse(bodyText) } catch {}
199
201
 
200
202
  // 📖 Non-2xx: return compact error code
201
203
  if (!resp.ok) {
@@ -217,11 +219,9 @@ export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeou
217
219
  if (data?.usage?.completion_tokens != null) {
218
220
  outputTokens = Number(data.usage.completion_tokens) || 0
219
221
  } else {
220
- // 📖 FALLBACK: estimate from character count when API omits usage
221
222
  outputTokens = estimateTokensFromText(content)
222
223
  }
223
224
 
224
- // 📖 Guard division by zero
225
225
  const seconds = totalMs / 1000
226
226
  const tokensPerSecond = seconds > 0 ? outputTokens / seconds : 0
227
227
 
@@ -245,3 +245,47 @@ export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeou
245
245
  clearTimeout(timer)
246
246
  }
247
247
  }
248
+
249
+ // 📖 benchmarkModel: Retry wrapper — up to BENCHMARK_MAX_RETRIES attempts with
250
+ // 📖 BENCHMARK_RETRY_DELAY_MS between failures. Models that are timeout, 429, down,
251
+ // 📖 or auth-failing may succeed on a later attempt. The `retries` field in the
252
+ // 📖 result tells the TUI how many attempts were needed (0 = first try, 2 = 3rd try).
253
+ // 📖
254
+ // 📖 Returns on success:
255
+ // 📖 { ok: true, totalMs, outputTokens, tokensPerSecond, answerPreview, retries }
256
+ // 📖
257
+ // 📖 Returns on failure (all attempts exhausted):
258
+ // 📖 { ok: false, code, totalMs, error, retries }
259
+ export async function benchmarkModel({ apiKey, modelId, providerKey, url, timeoutMs = BENCHMARK_TIMEOUT_MS, maxRetries = BENCHMARK_MAX_RETRIES, retryDelayMs = BENCHMARK_RETRY_DELAY_MS }) {
260
+ // 📖 Guard: unsupported providers that don't do chat completions
261
+ if (providerKey === 'rovo' || providerKey === 'gemini' || providerKey === 'opencode-zen') {
262
+ return {
263
+ ok: false,
264
+ code: 'UNSUPPORTED',
265
+ totalMs: 0,
266
+ error: 'Provider does not support chat completions',
267
+ retries: 0,
268
+ }
269
+ }
270
+
271
+ let lastResult = null
272
+
273
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
274
+ lastResult = await benchmarkSingleAttempt({ apiKey, modelId, providerKey, url, timeoutMs })
275
+
276
+ // 📖 Success — return immediately with retry count
277
+ if (lastResult.ok) {
278
+ lastResult.retries = attempt
279
+ return lastResult
280
+ }
281
+
282
+ // 📖 Failed — wait before retrying (skip delay on last attempt)
283
+ if (attempt < maxRetries - 1) {
284
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs))
285
+ }
286
+ }
287
+
288
+ // 📖 All attempts failed — return last error with retry count
289
+ lastResult.retries = maxRetries - 1
290
+ return lastResult
291
+ }
package/src/cli-help.js CHANGED
@@ -27,7 +27,7 @@ const ANALYSIS_FLAGS = [
27
27
  { flag: '--tier <S|A|B|C>', description: 'Filter models by tier family' },
28
28
  { flag: '--recommend', description: 'Open Smart Recommend immediately on startup' },
29
29
  { flag: '--premium', description: 'Start with S-tier filter + verdict sort (you can reset it in-app)' },
30
- { flag: '--sort <column>', description: 'Sort by column (rank, tier, origin, model, ping, avg, swe, ctx, condition, verdict, uptime, stability, usage)' },
30
+ { flag: '--sort <column>', description: 'Sort by column (rank, tier, origin, model, ping, avg, swe, ctx, condition, verdict, uptime, stability, aiLatency, tps)' },
31
31
  { flag: '--desc | --asc', description: 'Set sort direction (descending or ascending)' },
32
32
  { flag: '--origin <provider>', description: 'Filter models by provider origin' },
33
33
  { flag: '--ping-interval <ms>', description: 'Override ping interval in milliseconds' },
package/src/config.js CHANGED
@@ -242,6 +242,7 @@ function normalizeSettingsSection(settings) {
242
242
  ...safeSettings,
243
243
  hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
244
244
  favoritesPinnedAndSticky: typeof safeSettings.favoritesPinnedAndSticky === 'boolean' ? safeSettings.favoritesPinnedAndSticky : false,
245
+ runAiSpeedTestOnStartup: typeof safeSettings.runAiSpeedTestOnStartup === 'boolean' ? safeSettings.runAiSpeedTestOnStartup : false,
245
246
  theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
246
247
  }
247
248
  }
@@ -1007,7 +1008,7 @@ export function isProviderEnabled(config, providerKey) {
1007
1008
  /**
1008
1009
  * 📖 _emptyProfileSettings: Default TUI settings.
1009
1010
  *
1010
- * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, favoritesPinnedAndSticky: boolean, preferredToolMode: string, theme: string }}
1011
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, favoritesPinnedAndSticky: boolean, runAiSpeedTestOnStartup: boolean, preferredToolMode: string, theme: string }}
1011
1012
  */
1012
1013
  export function _emptyProfileSettings() {
1013
1014
  return {
@@ -1017,6 +1018,7 @@ export function _emptyProfileSettings() {
1017
1018
  pingInterval: 10000, // 📖 default ms between pings in the steady "normal" mode
1018
1019
  hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
1019
1020
  favoritesPinnedAndSticky: false, // 📖 default mode keeps favorites as normal starred rows; press Y to pin+stick them.
1021
+ runAiSpeedTestOnStartup: false, // 📖 opt-in: automatically fire the Ctrl+U global AI Speed Test after startup.
1020
1022
  preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
1021
1023
  theme: 'auto', // 📖 'auto' follows the terminal/OS theme, override with 'dark' or 'light' if needed
1022
1024
  }
@@ -782,6 +782,21 @@ export function createKeyHandler(ctx) {
782
782
  applyThemeSetting(cycleThemeSetting(currentTheme))
783
783
  }
784
784
 
785
+ function toggleStartupAiSpeedScan() {
786
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
787
+ state.config.settings.runAiSpeedTestOnStartup = state.config.settings.runAiSpeedTestOnStartup !== true
788
+ saveConfig(state.config)
789
+ state.settingsSyncStatus = {
790
+ type: 'success',
791
+ msg: state.config.settings.runAiSpeedTestOnStartup
792
+ ? '✅ Startup AI Speed Scan enabled — Ctrl+U benchmark will run after launch.'
793
+ : '✅ Startup AI Speed Scan disabled — use Ctrl+U manually when needed.',
794
+ }
795
+ trackAppAction('startup_ai_speed_scan_toggled', {
796
+ enabled: state.config.settings.runAiSpeedTestOnStartup === true,
797
+ })
798
+ }
799
+
785
800
  function toggleShellEnv() {
786
801
  if (!state.config.settings) state.config.settings = {}
787
802
  const currentlyEnabled = state.config.settings.shellEnvEnabled === true
@@ -877,6 +892,7 @@ export function createKeyHandler(ctx) {
877
892
  const visible = state.results.filter(r => !r.hidden)
878
893
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
879
894
  pinFavorites: state.favoritesPinnedAndSticky,
895
+ benchmarkResults: state.benchmarkResults,
880
896
  })
881
897
  if (resetCursor) {
882
898
  state.cursor = 0
@@ -1040,6 +1056,31 @@ export function createKeyHandler(ctx) {
1040
1056
  saveConfig(state.config)
1041
1057
  }
1042
1058
 
1059
+ // 📖 updateHealthFromBenchmark: When a benchmark reveals the real status of a model
1060
+ // 📖 (e.g. it was marked 'down' but actually returns 429, or was 'timeout' but now responds),
1061
+ // 📖 update the model's health status so the Health column stays accurate.
1062
+ function updateHealthFromBenchmark(model, result) {
1063
+ if (!result || result.ok) {
1064
+ // 📖 Benchmark succeeded → model is definitely up
1065
+ if (model.status !== 'up') model.status = 'up'
1066
+ return
1067
+ }
1068
+ const code = result.code
1069
+ if (code === 'TIMEOUT') {
1070
+ model.status = 'timeout'
1071
+ } else if (code === '401' || code === '403') {
1072
+ const hasKey = !!getApiKey(state.config, model.providerKey)
1073
+ model.status = hasKey ? 'auth_error' : 'noauth'
1074
+ model.httpCode = code
1075
+ } else if (code === '429') {
1076
+ model.status = 'down'
1077
+ model.httpCode = '429'
1078
+ } else if (code && code !== 'ERR' && code !== 'UNSUPPORTED') {
1079
+ model.status = 'down'
1080
+ model.httpCode = code
1081
+ }
1082
+ }
1083
+
1043
1084
  // 📖 runBenchmarkOnSelected: Fire a real-answer benchmark on the currently selected row.
1044
1085
  // 📖 Triggered by Ctrl+A. Async — does not block the UI. Results are stored in state
1045
1086
  // 📖 keyed by `${providerKey}/${modelId}` so they survive re-renders.
@@ -1052,7 +1093,8 @@ export function createKeyHandler(ctx) {
1052
1093
 
1053
1094
  const apiKey = getApiKey(state.config, selected.providerKey) ?? null
1054
1095
  const providerUrl = sources[selected.providerKey]?.url ?? null
1055
- if (!providerUrl) return
1096
+ // 📖 No skip on missing URL — let benchmarkModel handle it. The whole point of Ctrl+A
1097
+ // 📖 is to test models even when they're timeout, 429, down, or misconfigured.
1056
1098
 
1057
1099
  state.benchmarkRunning.add(benchmarkKey)
1058
1100
 
@@ -1064,6 +1106,7 @@ export function createKeyHandler(ctx) {
1064
1106
  url: providerUrl,
1065
1107
  })
1066
1108
  state.benchmarkResults[benchmarkKey] = result
1109
+ updateHealthFromBenchmark(selected, result)
1067
1110
  } catch (err) {
1068
1111
  state.benchmarkResults[benchmarkKey] = {
1069
1112
  ok: false,
@@ -1100,7 +1143,20 @@ export function createKeyHandler(ctx) {
1100
1143
  if (state.globalBenchmarkRunning) return
1101
1144
  state.globalBenchmarkRunning = true
1102
1145
 
1103
- const models = state.visibleSorted
1146
+ // 📖 Use state.results (ALL models) instead of state.visibleSorted so the benchmark
1147
+ // 📖 runs on every model regardless of TUI filters. Zero filtering.
1148
+ // 📖 Sort smart: UP models with low ping first (they finish fast and give instant feedback),
1149
+ // 📖 then timeout/down/429 models last (they take longer and may need retries).
1150
+ const healthPriority = { up: 0, pending: 1, timeout: 2, noauth: 3, auth_error: 4, down: 5 }
1151
+ const models = [...state.results].sort((a, b) => {
1152
+ const hpA = healthPriority[a.status] ?? 6
1153
+ const hpB = healthPriority[b.status] ?? 6
1154
+ if (hpA !== hpB) return hpA - hpB
1155
+ // 📖 Same health → sort by latest ping (lower first, timeouts/downtimes to end)
1156
+ const pingA = typeof a.pings?.[a.pings.length - 1]?.ms === 'number' ? a.pings[a.pings.length - 1].ms : 99999
1157
+ const pingB = typeof b.pings?.[b.pings.length - 1]?.ms === 'number' ? b.pings[b.pings.length - 1].ms : 99999
1158
+ return pingA - pingB
1159
+ })
1104
1160
  const total = models.length
1105
1161
  state.globalBenchmarkTotal = total
1106
1162
  state.globalBenchmarkCompleted = 0
@@ -1115,10 +1171,6 @@ export function createKeyHandler(ctx) {
1115
1171
 
1116
1172
  const apiKey = getApiKey(state.config, model.providerKey) ?? null
1117
1173
  const providerUrl = sources[model.providerKey]?.url ?? null
1118
- if (!providerUrl) {
1119
- state.globalBenchmarkCompleted++
1120
- return { skipped: true }
1121
- }
1122
1174
 
1123
1175
  state.benchmarkRunning.add(benchmarkKey)
1124
1176
  try {
@@ -1129,6 +1181,7 @@ export function createKeyHandler(ctx) {
1129
1181
  url: providerUrl,
1130
1182
  })
1131
1183
  state.benchmarkResults[benchmarkKey] = result
1184
+ updateHealthFromBenchmark(model, result)
1132
1185
  return { ok: result.ok }
1133
1186
  } catch (err) {
1134
1187
  state.benchmarkResults[benchmarkKey] = {
@@ -1462,7 +1515,9 @@ export function createKeyHandler(ctx) {
1462
1515
  }
1463
1516
 
1464
1517
  // 📖 Ctrl+U: Global AI Speed Benchmark (benchmark all visible models, 5 concurrent)
1465
- if (key.ctrl && key.name === 'u') {
1518
+ // 📖 Also handles the raw \x15 byte as a fallback for terminals where readline doesn't
1519
+ // 📖 set key.ctrl properly (same pattern as Ctrl+C → \x03 fallback).
1520
+ if ((key.ctrl && key.name === 'u') || str === '\x15') {
1466
1521
  await runGlobalBenchmark(state)
1467
1522
  return
1468
1523
  }
@@ -2473,7 +2528,8 @@ export function createKeyHandler(ctx) {
2473
2528
  const updateRowIdx = providerKeys.length
2474
2529
  const themeRowIdx = updateRowIdx + 1
2475
2530
  const favoritesModeRowIdx = themeRowIdx + 1
2476
- const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
2531
+ const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
2532
+ const cleanupLegacyProxyRowIdx = startupAiSpeedScanRowIdx + 1
2477
2533
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
2478
2534
  const shellEnvRowIdx = changelogViewRowIdx + 1
2479
2535
  // 📖 Profile system removed - API keys now persist permanently across all sessions
@@ -2630,6 +2686,11 @@ export function createKeyHandler(ctx) {
2630
2686
  return
2631
2687
  }
2632
2688
 
2689
+ if (state.settingsCursor === startupAiSpeedScanRowIdx) {
2690
+ toggleStartupAiSpeedScan()
2691
+ return
2692
+ }
2693
+
2633
2694
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
2634
2695
  runLegacyProxyCleanup()
2635
2696
  return
@@ -2683,6 +2744,10 @@ export function createKeyHandler(ctx) {
2683
2744
  toggleFavoritesDisplayMode()
2684
2745
  return
2685
2746
  }
2747
+ if (state.settingsCursor === startupAiSpeedScanRowIdx) {
2748
+ toggleStartupAiSpeedScan()
2749
+ return
2750
+ }
2686
2751
  // 📖 Profile system removed - API keys now persist permanently across all sessions
2687
2752
 
2688
2753
  // 📖 Toggle enabled/disabled for selected provider
@@ -2699,6 +2764,7 @@ export function createKeyHandler(ctx) {
2699
2764
  state.settingsCursor === updateRowIdx
2700
2765
  || state.settingsCursor === themeRowIdx
2701
2766
  || state.settingsCursor === favoritesModeRowIdx
2767
+ || state.settingsCursor === startupAiSpeedScanRowIdx
2702
2768
  || state.settingsCursor === cleanupLegacyProxyRowIdx
2703
2769
  || state.settingsCursor === changelogViewRowIdx
2704
2770
  ) return
@@ -3084,10 +3150,14 @@ export function createMouseEventHandler(ctx) {
3084
3150
  state.sortColumn = col
3085
3151
  state.sortDirection = 'asc'
3086
3152
  }
3153
+ // 📖 Trigger header flash animation (3 frames ≈ 250ms at 12 FPS)
3154
+ state.headerFlashColumn = col
3155
+ state.headerFlashUntilFrame = state.frame + 3
3087
3156
  // 📖 Recompute visible sorted list to reflect new sort order
3088
3157
  const visible = state.results.filter(r => !r.hidden)
3089
3158
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3090
3159
  pinFavorites: state.favoritesPinnedAndSticky,
3160
+ benchmarkResults: state.benchmarkResults,
3091
3161
  })
3092
3162
  }
3093
3163
 
@@ -3112,6 +3182,7 @@ export function createMouseEventHandler(ctx) {
3112
3182
  const visible = state.results.filter(r => !r.hidden)
3113
3183
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3114
3184
  pinFavorites: state.favoritesPinnedAndSticky,
3185
+ benchmarkResults: state.benchmarkResults,
3115
3186
  })
3116
3187
  // 📖 If we unfavorited while pinned mode is on, reset cursor to top
3117
3188
  if (wasFavorite && state.favoritesPinnedAndSticky) {
@@ -3422,11 +3493,14 @@ export function createMouseEventHandler(ctx) {
3422
3493
  persistUiSettings()
3423
3494
  } else if (col.name === 'tier') {
3424
3495
  // 📖 Clicking the Tier header cycles the tier filter (same as T key)
3496
+ state.headerFlashColumn = 'tier'
3497
+ state.headerFlashUntilFrame = state.frame + 3
3425
3498
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
3426
3499
  applyTierFilter()
3427
3500
  const visible = state.results.filter(r => !r.hidden)
3428
3501
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
3429
3502
  pinFavorites: state.favoritesPinnedAndSticky,
3503
+ benchmarkResults: state.benchmarkResults,
3430
3504
  })
3431
3505
  state.cursor = 0
3432
3506
  state.scrollOffset = 0
package/src/overlays.js CHANGED
@@ -118,7 +118,8 @@ export function createOverlayRenderers(state, deps) {
118
118
  const updateRowIdx = providerKeys.length
119
119
  const themeRowIdx = updateRowIdx + 1
120
120
  const favoritesModeRowIdx = themeRowIdx + 1
121
- const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
121
+ const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
122
+ const cleanupLegacyProxyRowIdx = startupAiSpeedScanRowIdx + 1
122
123
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
123
124
  const shellEnvRowIdx = changelogViewRowIdx + 1
124
125
  const EL = '\x1b[K'
@@ -259,6 +260,15 @@ export function createOverlayRenderers(state, deps) {
259
260
  cursorLineByRow[favoritesModeRowIdx] = lines.length
260
261
  lines.push(state.settingsCursor === favoritesModeRowIdx ? themeColors.bgCursorSettingsList(favoritesModeRow) : favoritesModeRow)
261
262
 
263
+ // 📖 Startup AI Speed Scan row controls the opt-in Ctrl+U auto-run at launch.
264
+ const startupAiSpeedScanEnabled = state.config.settings?.runAiSpeedTestOnStartup === true
265
+ const startupAiSpeedScanStatus = startupAiSpeedScanEnabled
266
+ ? themeColors.successBold('✅ Enabled — runs Ctrl+U after startup')
267
+ : themeColors.dim('❌ Disabled — manual Ctrl+U only')
268
+ const startupAiSpeedScanRow = `${bullet(state.settingsCursor === startupAiSpeedScanRowIdx)}${themeColors.textBold('Startup AI Speed Scan').padEnd(44)} ${startupAiSpeedScanStatus}`
269
+ cursorLineByRow[startupAiSpeedScanRowIdx] = lines.length
270
+ lines.push(state.settingsCursor === startupAiSpeedScanRowIdx ? themeColors.bgCursorSettingsList(startupAiSpeedScanRow) : startupAiSpeedScanRow)
271
+
262
272
  if (updateState === 'error' && state.settingsUpdateError) {
263
273
  lines.push(themeColors.error(` ${state.settingsUpdateError}`))
264
274
  }
@@ -290,7 +300,7 @@ export function createOverlayRenderers(state, deps) {
290
300
  if (state.settingsEditMode) {
291
301
  lines.push(themeColors.dim(' Type API key • Enter Save • Esc Cancel'))
292
302
  } else {
293
- lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Global theme • Y Favorites mode • Esc Close'))
303
+ lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Theme • Y Favorites • Esc Close'))
294
304
  }
295
305
  // 📖 Show sync/restore status message if set
296
306
  if (state.settingsSyncStatus) {
@@ -897,10 +907,10 @@ export function createOverlayRenderers(state, deps) {
897
907
  lines.push(` ${label('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${hint('Sort:')} ${key('O')} ${hint('Cycle:')} ${key('D')}`)
898
908
  lines.push(` ${hint('Same model on different providers can have very different speed and uptime.')}`)
899
909
  lines.push('')
900
- lines.push(` ${label('Latest')} Most recent ping response time (ms) ${hint('Sort:')} ${key('L')}`)
910
+ lines.push(` ${label('Last Ping')} Most recent ping response time (ms) ${hint('Sort:')} ${key('L')}`)
901
911
  lines.push(` ${hint('Shows how fast the server is responding right now — useful to catch live slowdowns.')}`)
902
912
  lines.push('')
903
- lines.push(` ${label('Avg Ping')} Average response time across all measurable pings (200 + 401) (ms) ${hint('Sort:')} ${key('A')}`)
913
+ lines.push(` ${label('Avg Ping')} Average response time across all measurable pings (200 + 401) (ms) ${hint('Sort:')} ${key('A')}`)
904
914
  lines.push(` ${hint('The long-term truth. Even without a key, a 401 still gives real latency so the average stays useful.')}`)
905
915
  lines.push('')
906
916
  lines.push(` ${label('Health')} Live status: ✅ UP / 🔥 429 / ⏳ TIMEOUT / ❌ ERR / 🔑 NO KEY ${hint('Sort:')} ${key('H')}`)
@@ -931,6 +941,7 @@ export function createOverlayRenderers(state, deps) {
931
941
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s → normal 10s → slow 30s → forced 4s)')}`)
932
942
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
933
943
  lines.push(` ${key('Ctrl+A')} AI Speed Test ${hint('(benchmark selected model → time + TPS)')}`)
944
+ lines.push(` ${key('Ctrl+U')} Global AI Speed Test ${hint('(benchmark all models; Settings can auto-run it on startup)')}`)
934
945
  lines.push(` ${key('E')} Cycle filter mode ${hint('(Normal → Configured only → Usable only)')}`)
935
946
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(📦 OpenCode → π Pi → 🪼 jcode → 📦 Desktop → 🦞 OpenClaw → 💘 Crush → 🪿 Goose → 🛠 Aider → 🐉 Qwen → 🤲 OpenHands → ⚡ Amp → 🦘 Rovo → ♊ Gemini)')}`)
936
947
  lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(1️⃣2️⃣3️⃣ = router fallback order, capped at 🔟)')}`)
@@ -202,7 +202,7 @@ export function calculateViewport(terminalRows, scrollOffset, totalModels, lineB
202
202
  // 📖 Non-favorites: active sort column/direction.
203
203
  // 📖 Models that are both recommended AND favorite — show in recommended section.
204
204
  // 📖 pinFavorites=false keeps favorites highlighted but lets normal sort/filter order apply.
205
- export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection, { pinFavorites = true } = {}) {
205
+ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection, { pinFavorites = true, benchmarkResults = {} } = {}) {
206
206
  if (!pinFavorites) {
207
207
  const recommendedRows = results
208
208
  .filter((r) => r.isRecommended)
@@ -210,7 +210,8 @@ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirectio
210
210
  const nonRecommendedRows = sortResults(
211
211
  results.filter((r) => !r.isRecommended),
212
212
  sortColumn,
213
- sortDirection
213
+ sortDirection,
214
+ { benchmarkResults }
214
215
  )
215
216
  return [...recommendedRows, ...nonRecommendedRows]
216
217
  }
@@ -224,7 +225,7 @@ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirectio
224
225
  const bothRows = results
225
226
  .filter((r) => r.isRecommended && r.isFavorite)
226
227
  .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
227
- const nonSpecialRows = sortResults(results.filter((r) => !r.isFavorite && !r.isRecommended), sortColumn, sortDirection)
228
+ const nonSpecialRows = sortResults(results.filter((r) => !r.isFavorite && !r.isRecommended), sortColumn, sortDirection, { benchmarkResults })
228
229
  return [...bothRows, ...recommendedRows, ...favoriteRows, ...nonSpecialRows]
229
230
  }
230
231