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 +4 -1
- package/changelog/v0.3.81.md +14 -0
- package/changelog/v0.4.0.md +23 -0
- package/package.json +1 -1
- package/src/app.js +21 -1
- package/src/benchmark.js +78 -34
- package/src/cli-help.js +1 -1
- package/src/config.js +3 -1
- package/src/key-handler.js +82 -8
- package/src/overlays.js +15 -4
- package/src/render-helpers.js +4 -3
- package/src/render-table.js +196 -92
- package/src/setup.js +16 -6
- package/src/theme.js +1 -1
- package/src/tui-state.js +5 -0
- package/src/utils.js +25 -1
- package/web/dist/assets/{index-DDz3_efL.js → index-A9aoSZsh.js} +1 -1
- package/web/dist/index.html +1 -1
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
|
+
"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
|
|
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
|
|
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
|
-
// 📖
|
|
64
|
-
|
|
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
|
-
|
|
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:
|
|
77
|
-
// 📖
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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,
|
|
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
|
}
|
package/src/key-handler.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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('
|
|
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')}
|
|
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 🔟)')}`)
|
package/src/render-helpers.js
CHANGED
|
@@ -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
|
|