free-coding-models 0.1.67 → 0.1.69
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 +99 -18
- package/bin/free-coding-models.js +788 -53
- package/lib/config.js +163 -4
- package/lib/utils.js +172 -5
- package/package.json +1 -1
- package/sources.js +45 -2
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* 🎯 Key features:
|
|
13
13
|
* - Parallel pings across all models with animated real-time updates (multi-provider)
|
|
14
|
-
* - Continuous monitoring with
|
|
14
|
+
* - Continuous monitoring with 60-second ping intervals (never stops)
|
|
15
15
|
* - Rolling averages calculated from ALL successful pings since start
|
|
16
16
|
* - Best-per-tier highlighting with medals (🥇🥈🥉)
|
|
17
17
|
* - Interactive navigation with arrow keys directly in the table
|
|
@@ -60,13 +60,14 @@
|
|
|
60
60
|
* ⚙️ Configuration:
|
|
61
61
|
* - API keys stored per-provider in ~/.free-coding-models.json (0600 perms)
|
|
62
62
|
* - Old ~/.free-coding-models plain-text auto-migrated as nvidia key on first run
|
|
63
|
-
* - 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, etc.
|
|
63
|
+
* - 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.
|
|
64
|
+
* - ZAI (z.ai) uses a non-standard base path; cloudflare needs CLOUDFLARE_ACCOUNT_ID in env.
|
|
64
65
|
* - Cloudflare Workers AI requires both CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_KEY) and CLOUDFLARE_ACCOUNT_ID
|
|
65
66
|
* - Models loaded from sources.js — all provider/model definitions are centralized there
|
|
66
67
|
* - OpenCode config: ~/.config/opencode/opencode.json
|
|
67
68
|
* - OpenClaw config: ~/.openclaw/openclaw.json
|
|
68
69
|
* - Ping timeout: 15s per attempt
|
|
69
|
-
* - Ping interval:
|
|
70
|
+
* - Ping interval: 60 seconds (continuous monitoring mode)
|
|
70
71
|
* - Animation: 12 FPS with braille spinners
|
|
71
72
|
*
|
|
72
73
|
* 🚀 CLI flags:
|
|
@@ -91,10 +92,12 @@ import { randomUUID } from 'crypto'
|
|
|
91
92
|
import { homedir } from 'os'
|
|
92
93
|
import { join, dirname } from 'path'
|
|
93
94
|
import { createServer } from 'net'
|
|
95
|
+
import { createServer as createHttpServer } from 'http'
|
|
96
|
+
import { request as httpsRequest } from 'https'
|
|
94
97
|
import { MODELS, sources } from '../sources.js'
|
|
95
98
|
import { patchOpenClawModelsJson } from '../patch-openclaw-models.js'
|
|
96
|
-
import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP } from '../lib/utils.js'
|
|
97
|
-
import { loadConfig, saveConfig, getApiKey, isProviderEnabled } from '../lib/config.js'
|
|
99
|
+
import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, findBestModel, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS } from '../lib/utils.js'
|
|
100
|
+
import { loadConfig, saveConfig, getApiKey, isProviderEnabled, saveAsProfile, loadProfile, listProfiles, deleteProfile, getActiveProfileName, setActiveProfile, _emptyProfileSettings } from '../lib/config.js'
|
|
98
101
|
|
|
99
102
|
const require = createRequire(import.meta.url)
|
|
100
103
|
const readline = require('readline')
|
|
@@ -717,7 +720,7 @@ const ALT_HOME = '\x1b[H'
|
|
|
717
720
|
// 📖 This allows easy addition of new model sources beyond NVIDIA NIM
|
|
718
721
|
|
|
719
722
|
const PING_TIMEOUT = 15_000 // 📖 15s per attempt before abort - slow models get more time
|
|
720
|
-
const PING_INTERVAL =
|
|
723
|
+
const PING_INTERVAL = 60_000 // 📖 60s between pings — avoids provider rate-limit bans
|
|
721
724
|
|
|
722
725
|
const FPS = 12
|
|
723
726
|
const COL_MODEL = 22
|
|
@@ -760,6 +763,7 @@ const spinCell = (f, o = 0) => chalk.dim.yellow(FRAMES[(f + o) % FRAMES.length].
|
|
|
760
763
|
// 📖 from the main table and from each other.
|
|
761
764
|
const SETTINGS_OVERLAY_BG = chalk.bgRgb(14, 20, 30)
|
|
762
765
|
const HELP_OVERLAY_BG = chalk.bgRgb(24, 16, 32)
|
|
766
|
+
const RECOMMEND_OVERLAY_BG = chalk.bgRgb(10, 25, 15) // 📖 Green tint for Smart Recommend
|
|
763
767
|
const OVERLAY_PANEL_WIDTH = 116
|
|
764
768
|
|
|
765
769
|
// 📖 Strip ANSI color/control sequences to estimate visible text width before padding.
|
|
@@ -852,7 +856,7 @@ function sliceOverlayLines(lines, offset, terminalRows) {
|
|
|
852
856
|
// 📖 Keep these constants in sync with renderTable() fixed shell lines.
|
|
853
857
|
// 📖 If this drifts, model rows overflow and can push the title row out of view.
|
|
854
858
|
const TABLE_HEADER_LINES = 4 // 📖 title, spacer, column headers, separator
|
|
855
|
-
const TABLE_FOOTER_LINES =
|
|
859
|
+
const TABLE_FOOTER_LINES = 7 // 📖 spacer, hints line 1, hints line 2, spacer, credit+contributors, discord, spacer
|
|
856
860
|
const TABLE_FIXED_LINES = TABLE_HEADER_LINES + TABLE_FOOTER_LINES
|
|
857
861
|
|
|
858
862
|
// 📖 Computes the visible slice of model rows that fits in the terminal.
|
|
@@ -871,18 +875,27 @@ function calculateViewport(terminalRows, scrollOffset, totalModels) {
|
|
|
871
875
|
return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
|
|
872
876
|
}
|
|
873
877
|
|
|
874
|
-
// 📖
|
|
875
|
-
// 📖
|
|
878
|
+
// 📖 Recommended models are pinned above favorites, favorites above non-favorites.
|
|
879
|
+
// 📖 Recommended: sorted by recommendation score (highest first).
|
|
880
|
+
// 📖 Favorites: keep insertion order (favoriteRank).
|
|
881
|
+
// 📖 Non-favorites: active sort column/direction.
|
|
876
882
|
function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
|
|
883
|
+
const recommendedRows = results
|
|
884
|
+
.filter((r) => r.isRecommended && !r.isFavorite)
|
|
885
|
+
.sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
|
|
877
886
|
const favoriteRows = results
|
|
878
|
-
.filter((r) => r.isFavorite)
|
|
887
|
+
.filter((r) => r.isFavorite && !r.isRecommended)
|
|
879
888
|
.sort((a, b) => a.favoriteRank - b.favoriteRank)
|
|
880
|
-
|
|
881
|
-
|
|
889
|
+
// 📖 Models that are both recommended AND favorite — show in recommended section
|
|
890
|
+
const bothRows = results
|
|
891
|
+
.filter((r) => r.isRecommended && r.isFavorite)
|
|
892
|
+
.sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
|
|
893
|
+
const nonSpecialRows = sortResults(results.filter((r) => !r.isFavorite && !r.isRecommended), sortColumn, sortDirection)
|
|
894
|
+
return [...bothRows, ...recommendedRows, ...favoriteRows, ...nonSpecialRows]
|
|
882
895
|
}
|
|
883
896
|
|
|
884
897
|
// 📖 renderTable: mode param controls footer hint text (opencode vs openclaw)
|
|
885
|
-
function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0) {
|
|
898
|
+
function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, originFilterMode = 0, activeProfile = null, profileSaveMode = false, profileSaveBuffer = '') {
|
|
886
899
|
// 📖 Filter out hidden models for display
|
|
887
900
|
const visibleResults = results.filter(r => !r.hidden)
|
|
888
901
|
|
|
@@ -934,6 +947,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
934
947
|
}
|
|
935
948
|
}
|
|
936
949
|
|
|
950
|
+
// 📖 Profile badge — shown when a named profile is active (Shift+P to cycle, Shift+S to save)
|
|
951
|
+
let profileBadge = ''
|
|
952
|
+
if (activeProfile) {
|
|
953
|
+
profileBadge = chalk.bold.rgb(200, 150, 255)(` [📋 ${activeProfile}]`)
|
|
954
|
+
}
|
|
955
|
+
|
|
937
956
|
// 📖 Column widths (generous spacing with margins)
|
|
938
957
|
const W_RANK = 6
|
|
939
958
|
const W_TIER = 6
|
|
@@ -952,7 +971,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
952
971
|
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
953
972
|
|
|
954
973
|
const lines = [
|
|
955
|
-
` ${chalk.bold('⚡ Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge} ` +
|
|
974
|
+
` ${chalk.bold('⚡ Free Coding Models')} ${chalk.dim('v' + LOCAL_VERSION)}${modeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
|
|
956
975
|
chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
|
|
957
976
|
chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
|
|
958
977
|
chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
|
|
@@ -1054,8 +1073,8 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1054
1073
|
const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
|
|
1055
1074
|
const source = chalk.green(providerName.padEnd(W_SOURCE))
|
|
1056
1075
|
// 📖 Favorites: always reserve 2 display columns at the start of Model column.
|
|
1057
|
-
// 📖 ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
|
|
1058
|
-
const favoritePrefix = r.isFavorite ? '⭐' : ' '
|
|
1076
|
+
// 📖 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
|
|
1077
|
+
const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
|
|
1059
1078
|
const prefixDisplayWidth = 2
|
|
1060
1079
|
const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
|
|
1061
1080
|
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
@@ -1241,6 +1260,9 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1241
1260
|
|
|
1242
1261
|
if (isCursor) {
|
|
1243
1262
|
lines.push(chalk.bgRgb(50, 0, 60)(row))
|
|
1263
|
+
} else if (r.isRecommended) {
|
|
1264
|
+
// 📖 Medium green background for recommended models (distinguishable from favorites)
|
|
1265
|
+
lines.push(chalk.bgRgb(15, 40, 15)(row))
|
|
1244
1266
|
} else if (r.isFavorite) {
|
|
1245
1267
|
lines.push(chalk.bgRgb(35, 20, 0)(row))
|
|
1246
1268
|
} else {
|
|
@@ -1252,7 +1274,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1252
1274
|
lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
|
|
1253
1275
|
}
|
|
1254
1276
|
|
|
1255
|
-
|
|
1277
|
+
// 📖 Profile save inline prompt — shown when Shift+S is pressed, replaces spacer line
|
|
1278
|
+
if (profileSaveMode) {
|
|
1279
|
+
lines.push(chalk.bgRgb(40, 20, 60)(` 📋 Save profile as: ${chalk.cyanBright(profileSaveBuffer + '▏')} ${chalk.dim('Enter save • Esc cancel')}`))
|
|
1280
|
+
} else {
|
|
1281
|
+
lines.push('')
|
|
1282
|
+
}
|
|
1256
1283
|
const intervalSec = Math.round(pingInterval / 1000)
|
|
1257
1284
|
|
|
1258
1285
|
// 📖 Footer hints adapt based on active mode
|
|
@@ -1261,7 +1288,10 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1261
1288
|
: mode === 'opencode-desktop'
|
|
1262
1289
|
? chalk.rgb(0, 200, 255)('Enter→OpenDesktop')
|
|
1263
1290
|
: chalk.rgb(0, 200, 255)('Enter→OpenCode')
|
|
1264
|
-
|
|
1291
|
+
// 📖 Line 1: core navigation + sorting shortcuts
|
|
1292
|
+
lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • `) + chalk.yellow('F') + chalk.dim(` Favorite • R/Y/O/M/L/A/S/C/H/V/B/U Sort • `) + chalk.yellow('T') + chalk.dim(` Tier • `) + chalk.yellow('N') + chalk.dim(` Origin • W↓/X↑ (${intervalSec}s) • `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode • `) + chalk.yellow('P') + chalk.dim(` Settings • `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
|
|
1293
|
+
// 📖 Line 2: profiles, recommend, and extended hints — gives visibility to less-obvious features
|
|
1294
|
+
lines.push(chalk.dim(` `) + chalk.rgb(200, 150, 255).bold('⇧P') + chalk.dim(` Cycle profile • `) + chalk.rgb(200, 150, 255).bold('⇧S') + chalk.dim(` Save profile • `) + chalk.rgb(0, 200, 180).bold('Q') + chalk.dim(` Smart Recommend • `) + chalk.yellow('E') + chalk.dim(`/`) + chalk.yellow('D') + chalk.dim(` Tier ↑↓ • `) + chalk.yellow('Esc') + chalk.dim(` Close overlay • Ctrl+C Exit`))
|
|
1265
1295
|
lines.push('')
|
|
1266
1296
|
lines.push(
|
|
1267
1297
|
chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
@@ -1271,6 +1301,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
|
|
|
1271
1301
|
chalk.dim(' • ') +
|
|
1272
1302
|
'🤝 ' +
|
|
1273
1303
|
chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
|
|
1304
|
+
chalk.dim(' (vava-nessa • erwinh22 • whit3rabbit • skylaweber)') +
|
|
1274
1305
|
chalk.dim(' • ') +
|
|
1275
1306
|
'💬 ' +
|
|
1276
1307
|
chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/5MbTnDC3Md\x1b\\Discord\x1b]8;;\x1b\\') +
|
|
@@ -1306,6 +1337,9 @@ function resolveCloudflareUrl(url) {
|
|
|
1306
1337
|
}
|
|
1307
1338
|
|
|
1308
1339
|
function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
1340
|
+
// 📖 ZAI models are stored as "zai/glm-..." in sources.js but the API expects just "glm-..."
|
|
1341
|
+
const apiModelId = providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
|
|
1342
|
+
|
|
1309
1343
|
if (providerKey === 'replicate') {
|
|
1310
1344
|
// 📖 Replicate uses /v1/predictions with a different payload than OpenAI chat-completions.
|
|
1311
1345
|
const replicateHeaders = { 'Content-Type': 'application/json', Prefer: 'wait=4' }
|
|
@@ -1324,7 +1358,7 @@ function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
|
1324
1358
|
return {
|
|
1325
1359
|
url: resolveCloudflareUrl(url),
|
|
1326
1360
|
headers,
|
|
1327
|
-
body: { model:
|
|
1361
|
+
body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
1328
1362
|
}
|
|
1329
1363
|
}
|
|
1330
1364
|
|
|
@@ -1339,7 +1373,7 @@ function buildPingRequest(apiKey, modelId, providerKey, url) {
|
|
|
1339
1373
|
return {
|
|
1340
1374
|
url,
|
|
1341
1375
|
headers,
|
|
1342
|
-
body: { model:
|
|
1376
|
+
body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
|
|
1343
1377
|
}
|
|
1344
1378
|
}
|
|
1345
1379
|
|
|
@@ -1387,6 +1421,8 @@ const OPENCODE_MODEL_MAP = {
|
|
|
1387
1421
|
}
|
|
1388
1422
|
|
|
1389
1423
|
function getOpenCodeModelId(providerKey, modelId) {
|
|
1424
|
+
// 📖 ZAI models stored as "zai/glm-..." but OpenCode expects just "glm-..."
|
|
1425
|
+
if (providerKey === 'zai') return modelId.replace(/^zai\//, '')
|
|
1390
1426
|
return OPENCODE_MODEL_MAP[providerKey]?.[modelId] || modelId
|
|
1391
1427
|
}
|
|
1392
1428
|
|
|
@@ -1409,6 +1445,7 @@ const ENV_VAR_NAMES = {
|
|
|
1409
1445
|
together: 'TOGETHER_API_KEY',
|
|
1410
1446
|
cloudflare: 'CLOUDFLARE_API_TOKEN',
|
|
1411
1447
|
perplexity: 'PERPLEXITY_API_KEY',
|
|
1448
|
+
zai: 'ZAI_API_KEY',
|
|
1412
1449
|
}
|
|
1413
1450
|
|
|
1414
1451
|
// 📖 Provider metadata used by the setup wizard and Settings details panel.
|
|
@@ -1533,17 +1570,25 @@ const PROVIDER_METADATA = {
|
|
|
1533
1570
|
signupHint: 'Generate API key (billing may be required)',
|
|
1534
1571
|
rateLimits: 'Tiered limits by spend (default ~50 RPM)',
|
|
1535
1572
|
},
|
|
1573
|
+
zai: {
|
|
1574
|
+
label: 'ZAI (z.ai)',
|
|
1575
|
+
color: chalk.rgb(0, 150, 255),
|
|
1576
|
+
signupUrl: 'https://z.ai',
|
|
1577
|
+
signupHint: 'Sign up and generate an API key',
|
|
1578
|
+
rateLimits: 'Free tier (generous quota)',
|
|
1579
|
+
},
|
|
1580
|
+
iflow: {
|
|
1581
|
+
label: 'iFlow',
|
|
1582
|
+
color: chalk.rgb(100, 200, 255),
|
|
1583
|
+
signupUrl: 'https://platform.iflow.cn',
|
|
1584
|
+
signupHint: 'Register → Personal Information → Generate API Key (7-day expiry)',
|
|
1585
|
+
rateLimits: 'Free for individuals (no request limits)',
|
|
1586
|
+
},
|
|
1536
1587
|
}
|
|
1537
1588
|
|
|
1538
|
-
// 📖 OpenCode config location
|
|
1539
|
-
// 📖
|
|
1540
|
-
|
|
1541
|
-
const OPENCODE_CONFIG = isWindows
|
|
1542
|
-
? join(homedir(), 'AppData', 'Roaming', 'opencode', 'opencode.json')
|
|
1543
|
-
: join(homedir(), '.config', 'opencode', 'opencode.json')
|
|
1544
|
-
|
|
1545
|
-
// 📖 Fallback to .config on Windows if AppData doesn't exist
|
|
1546
|
-
const OPENCODE_CONFIG_FALLBACK = join(homedir(), '.config', 'opencode', 'opencode.json')
|
|
1589
|
+
// 📖 OpenCode config location: ~/.config/opencode/opencode.json on ALL platforms.
|
|
1590
|
+
// 📖 OpenCode uses xdg-basedir which resolves to %USERPROFILE%\.config on Windows.
|
|
1591
|
+
const OPENCODE_CONFIG = join(homedir(), '.config', 'opencode', 'opencode.json')
|
|
1547
1592
|
const OPENCODE_PORT_RANGE_START = 4096
|
|
1548
1593
|
const OPENCODE_PORT_RANGE_END = 5096
|
|
1549
1594
|
|
|
@@ -1585,8 +1630,6 @@ async function resolveOpenCodeTmuxPort() {
|
|
|
1585
1630
|
}
|
|
1586
1631
|
|
|
1587
1632
|
function getOpenCodeConfigPath() {
|
|
1588
|
-
if (existsSync(OPENCODE_CONFIG)) return OPENCODE_CONFIG
|
|
1589
|
-
if (isWindows && existsSync(OPENCODE_CONFIG_FALLBACK)) return OPENCODE_CONFIG_FALLBACK
|
|
1590
1633
|
return OPENCODE_CONFIG
|
|
1591
1634
|
}
|
|
1592
1635
|
|
|
@@ -1629,14 +1672,68 @@ function checkNvidiaNimConfig() {
|
|
|
1629
1672
|
// 📖 Resolves the actual API key from config/env and passes it as an env var
|
|
1630
1673
|
// 📖 to the child process so OpenCode's {env:GROQ_API_KEY} references work
|
|
1631
1674
|
// 📖 even when the key is only in ~/.free-coding-models.json (not in shell env).
|
|
1632
|
-
|
|
1675
|
+
// 📖 createZaiProxy: Localhost reverse proxy that bridges ZAI's non-standard API paths
|
|
1676
|
+
// 📖 to OpenCode's expected /v1/* OpenAI-compatible format.
|
|
1677
|
+
// 📖 OpenCode's local provider calls GET /v1/models for discovery and POST /v1/chat/completions
|
|
1678
|
+
// 📖 for inference. ZAI's API lives at /api/coding/paas/v4/* instead — this proxy rewrites.
|
|
1679
|
+
// 📖 Returns { server, port } — caller must server.close() when done.
|
|
1680
|
+
async function createZaiProxy(apiKey) {
|
|
1681
|
+
const server = createHttpServer((req, res) => {
|
|
1682
|
+
let targetPath = req.url
|
|
1683
|
+
// 📖 Rewrite /v1/* → /api/coding/paas/v4/*
|
|
1684
|
+
if (targetPath.startsWith('/v1/')) {
|
|
1685
|
+
targetPath = '/api/coding/paas/v4/' + targetPath.slice(4)
|
|
1686
|
+
} else if (targetPath.startsWith('/v1')) {
|
|
1687
|
+
targetPath = '/api/coding/paas/v4' + targetPath.slice(3)
|
|
1688
|
+
} else {
|
|
1689
|
+
// 📖 Non /v1 paths (e.g. /api/v0/ health checks) — reject
|
|
1690
|
+
res.writeHead(404)
|
|
1691
|
+
res.end()
|
|
1692
|
+
return
|
|
1693
|
+
}
|
|
1694
|
+
const headers = { ...req.headers, host: 'api.z.ai' }
|
|
1695
|
+
if (apiKey) headers.authorization = `Bearer ${apiKey}`
|
|
1696
|
+
// 📖 Remove transfer-encoding to avoid chunked encoding issues with https.request
|
|
1697
|
+
delete headers['transfer-encoding']
|
|
1698
|
+
const proxyReq = httpsRequest({
|
|
1699
|
+
hostname: 'api.z.ai',
|
|
1700
|
+
port: 443,
|
|
1701
|
+
path: targetPath,
|
|
1702
|
+
method: req.method,
|
|
1703
|
+
headers,
|
|
1704
|
+
}, (proxyRes) => {
|
|
1705
|
+
res.writeHead(proxyRes.statusCode, proxyRes.headers)
|
|
1706
|
+
proxyRes.pipe(res)
|
|
1707
|
+
})
|
|
1708
|
+
proxyReq.on('error', () => { res.writeHead(502); res.end() })
|
|
1709
|
+
req.pipe(proxyReq)
|
|
1710
|
+
})
|
|
1711
|
+
await new Promise(r => server.listen(0, '127.0.0.1', r))
|
|
1712
|
+
return { server, port: server.address().port }
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = null) {
|
|
1633
1716
|
const envVarName = ENV_VAR_NAMES[providerKey]
|
|
1634
1717
|
const resolvedKey = getApiKey(fcmConfig, providerKey)
|
|
1635
1718
|
const childEnv = { ...process.env }
|
|
1719
|
+
// 📖 Suppress MaxListenersExceededWarning from @modelcontextprotocol/sdk
|
|
1720
|
+
// 📖 when 7+ MCP servers cause drain listener count to exceed default 10
|
|
1721
|
+
childEnv.NODE_NO_WARNINGS = '1'
|
|
1636
1722
|
const finalArgs = [...args]
|
|
1637
1723
|
const hasExplicitPortArg = finalArgs.includes('--port')
|
|
1638
1724
|
if (envVarName && resolvedKey) childEnv[envVarName] = resolvedKey
|
|
1639
1725
|
|
|
1726
|
+
// 📖 ZAI proxy: OpenCode's Go binary doesn't know about ZAI as a provider.
|
|
1727
|
+
// 📖 We spin up a localhost proxy that rewrites /v1/* → /api/coding/paas/v4/*
|
|
1728
|
+
// 📖 and register ZAI as a custom openai-compatible provider in opencode.json.
|
|
1729
|
+
// 📖 If startOpenCode already started the proxy, reuse it (existingZaiProxy).
|
|
1730
|
+
let zaiProxy = existingZaiProxy
|
|
1731
|
+
if (providerKey === 'zai' && resolvedKey && !zaiProxy) {
|
|
1732
|
+
const { server, port } = await createZaiProxy(resolvedKey)
|
|
1733
|
+
zaiProxy = server
|
|
1734
|
+
console.log(chalk.dim(` 🔀 ZAI proxy listening on port ${port} (rewrites /v1/* → ZAI API)`))
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1640
1737
|
// 📖 In tmux, OpenCode sub-agents need a listening port to open extra panes.
|
|
1641
1738
|
// 📖 We auto-pick one if the user did not provide --port explicitly.
|
|
1642
1739
|
if (process.env.TMUX && !hasExplicitPortArg) {
|
|
@@ -1664,8 +1761,22 @@ async function spawnOpenCode(args, providerKey, fcmConfig) {
|
|
|
1664
1761
|
})
|
|
1665
1762
|
|
|
1666
1763
|
return new Promise((resolve, reject) => {
|
|
1667
|
-
child.on('exit',
|
|
1764
|
+
child.on('exit', (code) => {
|
|
1765
|
+
if (zaiProxy) zaiProxy.close()
|
|
1766
|
+
// 📖 ZAI cleanup: remove the ephemeral proxy provider from opencode.json
|
|
1767
|
+
// 📖 so a stale baseURL doesn't cause "Model zai/… is not valid" on next launch
|
|
1768
|
+
if (providerKey === 'zai') {
|
|
1769
|
+
try {
|
|
1770
|
+
const cfg = loadOpenCodeConfig()
|
|
1771
|
+
if (cfg.provider?.zai) delete cfg.provider.zai
|
|
1772
|
+
if (typeof cfg.model === 'string' && cfg.model.startsWith('zai/')) delete cfg.model
|
|
1773
|
+
saveOpenCodeConfig(cfg)
|
|
1774
|
+
} catch { /* best-effort cleanup */ }
|
|
1775
|
+
}
|
|
1776
|
+
resolve(code)
|
|
1777
|
+
})
|
|
1668
1778
|
child.on('error', (err) => {
|
|
1779
|
+
if (zaiProxy) zaiProxy.close()
|
|
1669
1780
|
if (err.code === 'ENOENT') {
|
|
1670
1781
|
console.error(chalk.red('\n X Could not find "opencode" -- is it installed and in your PATH?'))
|
|
1671
1782
|
console.error(chalk.dim(' Install: npm i -g opencode or see https://opencode.ai'))
|
|
@@ -1775,8 +1886,72 @@ After installation, you can use: opencode --model ${modelRef}`
|
|
|
1775
1886
|
return
|
|
1776
1887
|
}
|
|
1777
1888
|
|
|
1778
|
-
// 📖
|
|
1779
|
-
// 📖
|
|
1889
|
+
// 📖 ZAI: OpenCode's Go binary has no built-in ZAI provider.
|
|
1890
|
+
// 📖 We start a localhost proxy that rewrites /v1/* → /api/coding/paas/v4/*
|
|
1891
|
+
// 📖 and register ZAI as a custom openai-compatible provider pointing to the proxy.
|
|
1892
|
+
// 📖 This gives OpenCode a standard provider/model format (zai/glm-5) it understands.
|
|
1893
|
+
if (providerKey === 'zai') {
|
|
1894
|
+
const resolvedKey = getApiKey(fcmConfig, providerKey)
|
|
1895
|
+
if (!resolvedKey) {
|
|
1896
|
+
console.log(chalk.yellow(' ⚠ ZAI API key not found. Set ZAI_API_KEY environment variable.'))
|
|
1897
|
+
console.log()
|
|
1898
|
+
return
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// 📖 Start proxy FIRST to get the port for config
|
|
1902
|
+
const { server: zaiProxyServer, port: zaiProxyPort } = await createZaiProxy(resolvedKey)
|
|
1903
|
+
console.log(chalk.dim(` 🔀 ZAI proxy listening on port ${zaiProxyPort} (rewrites /v1/* → ZAI API)`))
|
|
1904
|
+
|
|
1905
|
+
console.log(chalk.green(` 🚀 Setting ${chalk.bold(model.label)} as default…`))
|
|
1906
|
+
console.log(chalk.dim(` Model: ${modelRef}`))
|
|
1907
|
+
console.log()
|
|
1908
|
+
|
|
1909
|
+
const config = loadOpenCodeConfig()
|
|
1910
|
+
const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
|
|
1911
|
+
|
|
1912
|
+
if (existsSync(getOpenCodeConfigPath())) {
|
|
1913
|
+
copyFileSync(getOpenCodeConfigPath(), backupPath)
|
|
1914
|
+
console.log(chalk.dim(` 💾 Backup: ${backupPath}`))
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// 📖 Register ZAI as an openai-compatible provider pointing to our localhost proxy
|
|
1918
|
+
// 📖 apiKey is required by @ai-sdk/openai-compatible SDK — the proxy handles real auth internally
|
|
1919
|
+
if (!config.provider) config.provider = {}
|
|
1920
|
+
config.provider.zai = {
|
|
1921
|
+
npm: '@ai-sdk/openai-compatible',
|
|
1922
|
+
name: 'ZAI',
|
|
1923
|
+
options: {
|
|
1924
|
+
baseURL: `http://127.0.0.1:${zaiProxyPort}/v1`,
|
|
1925
|
+
apiKey: 'zai-proxy',
|
|
1926
|
+
},
|
|
1927
|
+
models: {}
|
|
1928
|
+
}
|
|
1929
|
+
config.provider.zai.models[ocModelId] = { name: model.label }
|
|
1930
|
+
config.model = modelRef
|
|
1931
|
+
|
|
1932
|
+
saveOpenCodeConfig(config)
|
|
1933
|
+
|
|
1934
|
+
const savedConfig = loadOpenCodeConfig()
|
|
1935
|
+
console.log(chalk.dim(` 📝 Config saved to: ${getOpenCodeConfigPath()}`))
|
|
1936
|
+
console.log(chalk.dim(` 📝 Default model in config: ${savedConfig.model || 'NOT SET'}`))
|
|
1937
|
+
console.log()
|
|
1938
|
+
|
|
1939
|
+
if (savedConfig.model === config.model) {
|
|
1940
|
+
console.log(chalk.green(` ✓ Default model set to: ${modelRef}`))
|
|
1941
|
+
} else {
|
|
1942
|
+
console.log(chalk.yellow(` ⚠ Config might not have been saved correctly`))
|
|
1943
|
+
}
|
|
1944
|
+
console.log()
|
|
1945
|
+
console.log(chalk.dim(' Starting OpenCode…'))
|
|
1946
|
+
console.log()
|
|
1947
|
+
|
|
1948
|
+
// 📖 Pass existing proxy to spawnOpenCode so it doesn't start a second one
|
|
1949
|
+
await spawnOpenCode(['--model', modelRef], providerKey, fcmConfig, zaiProxyServer)
|
|
1950
|
+
return
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// 📖 Groq: built-in OpenCode provider — needs provider block with apiKey in opencode.json.
|
|
1954
|
+
// 📖 Cerebras: NOT built-in — needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
|
|
1780
1955
|
// 📖 Both need the model registered in provider.<key>.models so OpenCode can find it.
|
|
1781
1956
|
console.log(chalk.green(` 🚀 Setting ${chalk.bold(model.label)} as default…`))
|
|
1782
1957
|
console.log(chalk.dim(` Model: ${modelRef}`))
|
|
@@ -2092,6 +2267,16 @@ ${isWindows ? 'set NVIDIA_API_KEY=your_key_here' : 'export NVIDIA_API_KEY=your_k
|
|
|
2092
2267
|
return
|
|
2093
2268
|
}
|
|
2094
2269
|
|
|
2270
|
+
// 📖 ZAI: Desktop mode can't use the localhost proxy (Desktop is a standalone app).
|
|
2271
|
+
// 📖 Direct the user to use OpenCode CLI mode instead, which supports ZAI via proxy.
|
|
2272
|
+
if (providerKey === 'zai') {
|
|
2273
|
+
console.log(chalk.yellow(' ⚠ ZAI models are supported in OpenCode CLI mode only (not Desktop).'))
|
|
2274
|
+
console.log(chalk.dim(' Reason: ZAI requires a localhost proxy that only works with the CLI spawn.'))
|
|
2275
|
+
console.log(chalk.dim(' Use OpenCode CLI mode (default) to launch ZAI models.'))
|
|
2276
|
+
console.log()
|
|
2277
|
+
return
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2095
2280
|
// 📖 Groq: built-in OpenCode provider — needs provider block with apiKey in opencode.json.
|
|
2096
2281
|
// 📖 Cerebras: NOT built-in — needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
|
|
2097
2282
|
// 📖 Both need the model registered in provider.<key>.models so OpenCode can find it.
|
|
@@ -2454,6 +2639,16 @@ async function main() {
|
|
|
2454
2639
|
ensureTelemetryConfig(config)
|
|
2455
2640
|
ensureFavoritesConfig(config)
|
|
2456
2641
|
|
|
2642
|
+
// 📖 If --profile <name> was passed, load that profile into the live config
|
|
2643
|
+
if (cliArgs.profileName) {
|
|
2644
|
+
const profileSettings = loadProfile(config, cliArgs.profileName)
|
|
2645
|
+
if (!profileSettings) {
|
|
2646
|
+
console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
|
|
2647
|
+
process.exit(1)
|
|
2648
|
+
}
|
|
2649
|
+
saveConfig(config)
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2457
2652
|
// 📖 Check if any provider has a key — if not, run the first-time setup wizard
|
|
2458
2653
|
const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
|
|
2459
2654
|
|
|
@@ -2597,6 +2792,22 @@ async function main() {
|
|
|
2597
2792
|
helpVisible: false, // 📖 Whether the help overlay (K key) is active
|
|
2598
2793
|
settingsScrollOffset: 0, // 📖 Vertical scroll offset for Settings overlay viewport
|
|
2599
2794
|
helpScrollOffset: 0, // 📖 Vertical scroll offset for Help overlay viewport
|
|
2795
|
+
// 📖 Smart Recommend overlay state (Q key opens it)
|
|
2796
|
+
recommendOpen: false, // 📖 Whether the recommend overlay is active
|
|
2797
|
+
recommendPhase: 'questionnaire', // 📖 'questionnaire'|'analyzing'|'results' — current phase
|
|
2798
|
+
recommendCursor: 0, // 📖 Selected question option (0-based index within current question)
|
|
2799
|
+
recommendQuestion: 0, // 📖 Which question we're on (0=task, 1=priority, 2=context)
|
|
2800
|
+
recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // 📖 User's answers
|
|
2801
|
+
recommendProgress: 0, // 📖 Analysis progress percentage (0–100)
|
|
2802
|
+
recommendResults: [], // 📖 Top N recommendations from getTopRecommendations()
|
|
2803
|
+
recommendScrollOffset: 0, // 📖 Vertical scroll offset for Recommend overlay viewport
|
|
2804
|
+
recommendAnalysisTimer: null, // 📖 setInterval handle for the 10s analysis phase
|
|
2805
|
+
recommendPingTimer: null, // 📖 setInterval handle for 2 pings/sec during analysis
|
|
2806
|
+
recommendedKeys: new Set(), // 📖 Set of "providerKey/modelId" for recommended models (shown in main table)
|
|
2807
|
+
// 📖 Config Profiles state
|
|
2808
|
+
activeProfile: getActiveProfileName(config), // 📖 Currently loaded profile name (or null)
|
|
2809
|
+
profileSaveMode: false, // 📖 Whether the inline "Save profile" name input is active
|
|
2810
|
+
profileSaveBuffer: '', // 📖 Typed characters for the profile name being saved
|
|
2600
2811
|
}
|
|
2601
2812
|
|
|
2602
2813
|
// 📖 Re-clamp viewport on terminal resize
|
|
@@ -2762,11 +2973,39 @@ async function main() {
|
|
|
2762
2973
|
lines.push(chalk.red(` ${state.settingsUpdateError}`))
|
|
2763
2974
|
}
|
|
2764
2975
|
|
|
2976
|
+
// 📖 Profiles section — list saved profiles with active indicator + delete support
|
|
2977
|
+
const savedProfiles = listProfiles(state.config)
|
|
2978
|
+
const profileStartIdx = updateRowIdx + 1
|
|
2979
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
|
|
2980
|
+
|
|
2981
|
+
lines.push('')
|
|
2982
|
+
lines.push(` ${chalk.bold('📋 Profiles')} ${chalk.dim(savedProfiles.length > 0 ? `(${savedProfiles.length} saved)` : '(none — press Shift+S in main view to save)')}`)
|
|
2983
|
+
lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
|
|
2984
|
+
lines.push('')
|
|
2985
|
+
|
|
2986
|
+
if (savedProfiles.length === 0) {
|
|
2987
|
+
lines.push(chalk.dim(' No saved profiles. Press Shift+S in the main table to save your current settings as a profile.'))
|
|
2988
|
+
} else {
|
|
2989
|
+
for (let i = 0; i < savedProfiles.length; i++) {
|
|
2990
|
+
const pName = savedProfiles[i]
|
|
2991
|
+
const rowIdx = profileStartIdx + i
|
|
2992
|
+
const isCursor = state.settingsCursor === rowIdx
|
|
2993
|
+
const isActive = state.activeProfile === pName
|
|
2994
|
+
const activeBadge = isActive ? chalk.greenBright(' ✅ active') : ''
|
|
2995
|
+
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
2996
|
+
const profileLabel = chalk.rgb(200, 150, 255).bold(pName.padEnd(30))
|
|
2997
|
+
const deleteHint = isCursor ? chalk.dim(' Enter→Load • Backspace→Delete') : ''
|
|
2998
|
+
const row = `${bullet}${profileLabel}${activeBadge}${deleteHint}`
|
|
2999
|
+
cursorLineByRow[rowIdx] = lines.length
|
|
3000
|
+
lines.push(isCursor ? chalk.bgRgb(40, 20, 60)(row) : row)
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
|
|
2765
3004
|
lines.push('')
|
|
2766
3005
|
if (state.settingsEditMode) {
|
|
2767
3006
|
lines.push(chalk.dim(' Type API key • Enter Save • Esc Cancel'))
|
|
2768
3007
|
} else {
|
|
2769
|
-
lines.push(chalk.dim(' ↑↓ Navigate • Enter Edit key / Toggle
|
|
3008
|
+
lines.push(chalk.dim(' ↑↓ Navigate • Enter Edit key / Toggle / Load profile • Space Toggle • T Test key • U Updates • ⌫ Delete profile • Esc Close'))
|
|
2770
3009
|
}
|
|
2771
3010
|
lines.push('')
|
|
2772
3011
|
|
|
@@ -2800,7 +3039,7 @@ async function main() {
|
|
|
2800
3039
|
lines.push(` ${chalk.cyan('Rank')} SWE-bench rank (1 = best coding score) ${chalk.dim('Sort:')} ${chalk.yellow('R')}`)
|
|
2801
3040
|
lines.push(` ${chalk.dim('Quick glance at which model is objectively the best coder right now.')}`)
|
|
2802
3041
|
lines.push('')
|
|
2803
|
-
lines.push(` ${chalk.cyan('Tier')} S+ / S / A+ / A / A- / B+ / B / C based on SWE-bench score ${chalk.dim('Sort:')} ${chalk.yellow('Y')}`)
|
|
3042
|
+
lines.push(` ${chalk.cyan('Tier')} S+ / S / A+ / A / A- / B+ / B / C based on SWE-bench score ${chalk.dim('Sort:')} ${chalk.yellow('Y')} ${chalk.dim('Cycle:')} ${chalk.yellow('T')}`)
|
|
2804
3043
|
lines.push(` ${chalk.dim('Skip the noise — S/S+ models solve real GitHub issues, C models are for light tasks.')}`)
|
|
2805
3044
|
lines.push('')
|
|
2806
3045
|
lines.push(` ${chalk.cyan('SWE%')} SWE-bench score — coding ability benchmark (color-coded) ${chalk.dim('Sort:')} ${chalk.yellow('S')}`)
|
|
@@ -2812,7 +3051,7 @@ async function main() {
|
|
|
2812
3051
|
lines.push(` ${chalk.cyan('Model')} Model name (⭐ = favorited, pinned at top) ${chalk.dim('Sort:')} ${chalk.yellow('M')} ${chalk.dim('Favorite:')} ${chalk.yellow('F')}`)
|
|
2813
3052
|
lines.push(` ${chalk.dim('Star the ones you like — they stay pinned at the top across restarts.')}`)
|
|
2814
3053
|
lines.push('')
|
|
2815
|
-
lines.push(` ${chalk.cyan('Origin')} Provider source (NIM, Groq, Cerebras, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('O')} ${chalk.dim('
|
|
3054
|
+
lines.push(` ${chalk.cyan('Origin')} Provider source (NIM, Groq, Cerebras, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('O')} ${chalk.dim('Cycle:')} ${chalk.yellow('N')}`)
|
|
2816
3055
|
lines.push(` ${chalk.dim('Same model on different providers can have very different speed and uptime.')}`)
|
|
2817
3056
|
lines.push('')
|
|
2818
3057
|
lines.push(` ${chalk.cyan('Latest')} Most recent ping response time (ms) ${chalk.dim('Sort:')} ${chalk.yellow('L')}`)
|
|
@@ -2839,16 +3078,17 @@ async function main() {
|
|
|
2839
3078
|
lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
|
|
2840
3079
|
lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
|
|
2841
3080
|
lines.push('')
|
|
2842
|
-
lines.push(` ${chalk.bold('Filters')}`)
|
|
2843
|
-
lines.push(` ${chalk.yellow('T')} Cycle tier filter ${chalk.dim('(All → S+ → S → A+ → A → A- → B+ → B → C → All)')}`)
|
|
2844
|
-
lines.push(` ${chalk.yellow('N')} Cycle origin filter ${chalk.dim('(All → NIM → Groq → Cerebras → ... each provider → All)')}`)
|
|
2845
|
-
lines.push('')
|
|
2846
3081
|
lines.push(` ${chalk.bold('Controls')}`)
|
|
2847
3082
|
lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
|
|
2848
3083
|
lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
|
|
2849
3084
|
lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI → OpenCode Desktop → OpenClaw)')}`)
|
|
2850
3085
|
lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
|
|
3086
|
+
lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
|
|
2851
3087
|
lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics, manual update)')}`)
|
|
3088
|
+
lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
|
|
3089
|
+
lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt — type name + Enter)')}`)
|
|
3090
|
+
lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
|
|
3091
|
+
lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
|
|
2852
3092
|
lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
|
|
2853
3093
|
lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
|
|
2854
3094
|
lines.push('')
|
|
@@ -2871,6 +3111,8 @@ async function main() {
|
|
|
2871
3111
|
lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
|
|
2872
3112
|
lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
|
|
2873
3113
|
lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
|
|
3114
|
+
lines.push(` ${chalk.cyan('free-coding-models --recommend')} ${chalk.dim('Auto-open Smart Recommend on start')}`)
|
|
3115
|
+
lines.push(` ${chalk.cyan('free-coding-models --profile <name>')} ${chalk.dim('Load a saved config profile')}`)
|
|
2874
3116
|
lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
|
|
2875
3117
|
lines.push('')
|
|
2876
3118
|
// 📖 Help overlay can be longer than viewport, so keep a dedicated scroll offset.
|
|
@@ -2881,6 +3123,211 @@ async function main() {
|
|
|
2881
3123
|
return cleared.join('\n')
|
|
2882
3124
|
}
|
|
2883
3125
|
|
|
3126
|
+
// ─── Smart Recommend overlay renderer ─────────────────────────────────────
|
|
3127
|
+
// 📖 renderRecommend: Draw the Smart Recommend overlay with 3 phases:
|
|
3128
|
+
// 1. 'questionnaire' — ask 3 questions (task type, priority, context budget)
|
|
3129
|
+
// 2. 'analyzing' — loading screen with progress bar (10s, 2 pings/sec)
|
|
3130
|
+
// 3. 'results' — show Top 3 recommendations with scores
|
|
3131
|
+
function renderRecommend() {
|
|
3132
|
+
const EL = '\x1b[K'
|
|
3133
|
+
const lines = []
|
|
3134
|
+
|
|
3135
|
+
lines.push('')
|
|
3136
|
+
lines.push(` ${chalk.bold('🎯 Smart Recommend')} ${chalk.dim('— find the best model for your task')}`)
|
|
3137
|
+
lines.push('')
|
|
3138
|
+
|
|
3139
|
+
if (state.recommendPhase === 'questionnaire') {
|
|
3140
|
+
// 📖 Question definitions — each has a title, options array, and answer key
|
|
3141
|
+
const questions = [
|
|
3142
|
+
{
|
|
3143
|
+
title: 'What are you working on?',
|
|
3144
|
+
options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: val.label })),
|
|
3145
|
+
answerKey: 'taskType',
|
|
3146
|
+
},
|
|
3147
|
+
{
|
|
3148
|
+
title: 'What matters most?',
|
|
3149
|
+
options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: val.label })),
|
|
3150
|
+
answerKey: 'priority',
|
|
3151
|
+
},
|
|
3152
|
+
{
|
|
3153
|
+
title: 'How big is your context?',
|
|
3154
|
+
options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: val.label })),
|
|
3155
|
+
answerKey: 'contextBudget',
|
|
3156
|
+
},
|
|
3157
|
+
]
|
|
3158
|
+
|
|
3159
|
+
const q = questions[state.recommendQuestion]
|
|
3160
|
+
const qNum = state.recommendQuestion + 1
|
|
3161
|
+
const qTotal = questions.length
|
|
3162
|
+
|
|
3163
|
+
// 📖 Progress breadcrumbs showing answered questions
|
|
3164
|
+
let breadcrumbs = ''
|
|
3165
|
+
for (let i = 0; i < questions.length; i++) {
|
|
3166
|
+
const answered = state.recommendAnswers[questions[i].answerKey]
|
|
3167
|
+
if (i < state.recommendQuestion && answered) {
|
|
3168
|
+
const answeredLabel = questions[i].options.find(o => o.key === answered)?.label || answered
|
|
3169
|
+
breadcrumbs += chalk.greenBright(` ✓ ${questions[i].title} ${chalk.bold(answeredLabel)}`) + '\n'
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
if (breadcrumbs) {
|
|
3173
|
+
lines.push(breadcrumbs.trimEnd())
|
|
3174
|
+
lines.push('')
|
|
3175
|
+
}
|
|
3176
|
+
|
|
3177
|
+
lines.push(` ${chalk.bold(`Question ${qNum}/${qTotal}:`)} ${chalk.cyan(q.title)}`)
|
|
3178
|
+
lines.push('')
|
|
3179
|
+
|
|
3180
|
+
for (let i = 0; i < q.options.length; i++) {
|
|
3181
|
+
const opt = q.options[i]
|
|
3182
|
+
const isCursor = i === state.recommendCursor
|
|
3183
|
+
const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
|
|
3184
|
+
const label = isCursor ? chalk.bold.white(opt.label) : chalk.white(opt.label)
|
|
3185
|
+
lines.push(`${bullet}${label}`)
|
|
3186
|
+
}
|
|
3187
|
+
|
|
3188
|
+
lines.push('')
|
|
3189
|
+
lines.push(chalk.dim(' ↑↓ navigate • Enter select • Esc cancel'))
|
|
3190
|
+
|
|
3191
|
+
} else if (state.recommendPhase === 'analyzing') {
|
|
3192
|
+
// 📖 Loading screen with progress bar
|
|
3193
|
+
const pct = Math.min(100, Math.round(state.recommendProgress))
|
|
3194
|
+
const barWidth = 40
|
|
3195
|
+
const filled = Math.round(barWidth * pct / 100)
|
|
3196
|
+
const empty = barWidth - filled
|
|
3197
|
+
const bar = chalk.greenBright('█'.repeat(filled)) + chalk.dim('░'.repeat(empty))
|
|
3198
|
+
|
|
3199
|
+
lines.push(` ${chalk.bold('Analyzing models...')}`)
|
|
3200
|
+
lines.push('')
|
|
3201
|
+
lines.push(` ${bar} ${chalk.bold(String(pct) + '%')}`)
|
|
3202
|
+
lines.push('')
|
|
3203
|
+
|
|
3204
|
+
// 📖 Show what we're doing
|
|
3205
|
+
const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
|
|
3206
|
+
const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
|
|
3207
|
+
const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
|
|
3208
|
+
lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
|
|
3209
|
+
lines.push('')
|
|
3210
|
+
|
|
3211
|
+
// 📖 Spinning indicator
|
|
3212
|
+
const spinIdx = state.frame % FRAMES.length
|
|
3213
|
+
lines.push(` ${chalk.yellow(FRAMES[spinIdx])} Pinging models at 2 pings/sec to gather fresh latency data...`)
|
|
3214
|
+
lines.push('')
|
|
3215
|
+
lines.push(chalk.dim(' Esc to cancel'))
|
|
3216
|
+
|
|
3217
|
+
} else if (state.recommendPhase === 'results') {
|
|
3218
|
+
// 📖 Show Top 3 results with detailed info
|
|
3219
|
+
const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || '—'
|
|
3220
|
+
const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || '—'
|
|
3221
|
+
const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || '—'
|
|
3222
|
+
lines.push(chalk.dim(` Task: ${taskLabel} • Priority: ${prioLabel} • Context: ${ctxLabel}`))
|
|
3223
|
+
lines.push('')
|
|
3224
|
+
|
|
3225
|
+
if (state.recommendResults.length === 0) {
|
|
3226
|
+
lines.push(` ${chalk.yellow('No models could be scored. Try different criteria or wait for more pings.')}`)
|
|
3227
|
+
} else {
|
|
3228
|
+
lines.push(` ${chalk.bold('Top Recommendations:')}`)
|
|
3229
|
+
lines.push('')
|
|
3230
|
+
|
|
3231
|
+
for (let i = 0; i < state.recommendResults.length; i++) {
|
|
3232
|
+
const rec = state.recommendResults[i]
|
|
3233
|
+
const r = rec.result
|
|
3234
|
+
const medal = i === 0 ? '🥇' : i === 1 ? '🥈' : '🥉'
|
|
3235
|
+
const providerName = sources[r.providerKey]?.name ?? r.providerKey
|
|
3236
|
+
const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
|
|
3237
|
+
const avg = getAvg(r)
|
|
3238
|
+
const avgStr = avg === Infinity ? '—' : Math.round(avg) + 'ms'
|
|
3239
|
+
const sweStr = r.sweScore ?? '—'
|
|
3240
|
+
const ctxStr = r.ctx ?? '—'
|
|
3241
|
+
const stability = getStabilityScore(r)
|
|
3242
|
+
const stabStr = stability === -1 ? '—' : String(stability)
|
|
3243
|
+
|
|
3244
|
+
const isCursor = i === state.recommendCursor
|
|
3245
|
+
const highlight = isCursor ? chalk.bgRgb(20, 50, 25) : (s => s)
|
|
3246
|
+
|
|
3247
|
+
lines.push(highlight(` ${medal} ${chalk.bold('#' + (i + 1))} ${chalk.bold.white(r.label)} ${chalk.dim('(' + providerName + ')')}`))
|
|
3248
|
+
lines.push(highlight(` Score: ${chalk.bold.greenBright(String(rec.score) + '/100')} │ Tier: ${tierFn(r.tier)} │ SWE: ${chalk.cyan(sweStr)} │ Avg: ${chalk.yellow(avgStr)} │ CTX: ${chalk.cyan(ctxStr)} │ Stability: ${chalk.cyan(stabStr)}`))
|
|
3249
|
+
lines.push('')
|
|
3250
|
+
}
|
|
3251
|
+
}
|
|
3252
|
+
|
|
3253
|
+
lines.push('')
|
|
3254
|
+
lines.push(` ${chalk.dim('These models are now')} ${chalk.greenBright('highlighted')} ${chalk.dim('and')} 🎯 ${chalk.dim('pinned in the main table.')}`)
|
|
3255
|
+
lines.push('')
|
|
3256
|
+
lines.push(chalk.dim(' ↑↓ navigate • Enter select & close • Esc close • Q new search'))
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
lines.push('')
|
|
3260
|
+
const { visible, offset } = sliceOverlayLines(lines, state.recommendScrollOffset, state.terminalRows)
|
|
3261
|
+
state.recommendScrollOffset = offset
|
|
3262
|
+
const tintedLines = tintOverlayLines(visible, RECOMMEND_OVERLAY_BG)
|
|
3263
|
+
const cleared2 = tintedLines.map(l => l + EL)
|
|
3264
|
+
return cleared2.join('\n')
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// ─── Smart Recommend: analysis phase controller ────────────────────────────
|
|
3268
|
+
// 📖 startRecommendAnalysis: begins the 10-second analysis phase.
|
|
3269
|
+
// 📖 Pings a random subset of visible models at 2 pings/sec while advancing progress.
|
|
3270
|
+
// 📖 After 10 seconds, computes recommendations and transitions to results phase.
|
|
3271
|
+
function startRecommendAnalysis() {
|
|
3272
|
+
state.recommendPhase = 'analyzing'
|
|
3273
|
+
state.recommendProgress = 0
|
|
3274
|
+
state.recommendResults = []
|
|
3275
|
+
|
|
3276
|
+
const startTime = Date.now()
|
|
3277
|
+
const ANALYSIS_DURATION = 10_000 // 📖 10 seconds
|
|
3278
|
+
const PING_RATE = 500 // 📖 2 pings per second (every 500ms)
|
|
3279
|
+
|
|
3280
|
+
// 📖 Progress updater — runs every 200ms to update the progress bar
|
|
3281
|
+
state.recommendAnalysisTimer = setInterval(() => {
|
|
3282
|
+
const elapsed = Date.now() - startTime
|
|
3283
|
+
state.recommendProgress = Math.min(100, (elapsed / ANALYSIS_DURATION) * 100)
|
|
3284
|
+
|
|
3285
|
+
if (elapsed >= ANALYSIS_DURATION) {
|
|
3286
|
+
// 📖 Analysis complete — compute recommendations
|
|
3287
|
+
clearInterval(state.recommendAnalysisTimer)
|
|
3288
|
+
clearInterval(state.recommendPingTimer)
|
|
3289
|
+
state.recommendAnalysisTimer = null
|
|
3290
|
+
state.recommendPingTimer = null
|
|
3291
|
+
|
|
3292
|
+
const recs = getTopRecommendations(
|
|
3293
|
+
state.results,
|
|
3294
|
+
state.recommendAnswers.taskType,
|
|
3295
|
+
state.recommendAnswers.priority,
|
|
3296
|
+
state.recommendAnswers.contextBudget,
|
|
3297
|
+
3
|
|
3298
|
+
)
|
|
3299
|
+
state.recommendResults = recs
|
|
3300
|
+
state.recommendPhase = 'results'
|
|
3301
|
+
state.recommendCursor = 0
|
|
3302
|
+
|
|
3303
|
+
// 📖 Mark recommended models so the main table can highlight them
|
|
3304
|
+
state.recommendedKeys = new Set(recs.map(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId)))
|
|
3305
|
+
// 📖 Tag each result object so sortResultsWithPinnedFavorites can pin them
|
|
3306
|
+
state.results.forEach(r => {
|
|
3307
|
+
const key = toFavoriteKey(r.providerKey, r.modelId)
|
|
3308
|
+
const rec = recs.find(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId) === key)
|
|
3309
|
+
r.isRecommended = !!rec
|
|
3310
|
+
r.recommendScore = rec ? rec.score : 0
|
|
3311
|
+
})
|
|
3312
|
+
}
|
|
3313
|
+
}, 200)
|
|
3314
|
+
|
|
3315
|
+
// 📖 Targeted pinging — ping random visible models at 2/sec for fresh data
|
|
3316
|
+
state.recommendPingTimer = setInterval(() => {
|
|
3317
|
+
const visible = state.results.filter(r => !r.hidden && r.status !== 'noauth')
|
|
3318
|
+
if (visible.length === 0) return
|
|
3319
|
+
// 📖 Pick a random model to ping — spreads load across all models over 10s
|
|
3320
|
+
const target = visible[Math.floor(Math.random() * visible.length)]
|
|
3321
|
+
pingModel(target).catch(() => {})
|
|
3322
|
+
}, PING_RATE)
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// 📖 stopRecommendAnalysis: cleanup timers if user cancels during analysis
|
|
3326
|
+
function stopRecommendAnalysis() {
|
|
3327
|
+
if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
|
|
3328
|
+
if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
|
|
3329
|
+
}
|
|
3330
|
+
|
|
2884
3331
|
// ─── Settings key test helper ───────────────────────────────────────────────
|
|
2885
3332
|
// 📖 Fires a single ping to the selected provider to verify the API key works.
|
|
2886
3333
|
async function testProviderKey(providerKey) {
|
|
@@ -2952,6 +3399,45 @@ async function main() {
|
|
|
2952
3399
|
const onKeyPress = async (str, key) => {
|
|
2953
3400
|
if (!key) return
|
|
2954
3401
|
|
|
3402
|
+
// 📖 Profile save mode: intercept ALL keys while inline name input is active.
|
|
3403
|
+
// 📖 Enter → save, Esc → cancel, Backspace → delete char, printable → append to buffer.
|
|
3404
|
+
if (state.profileSaveMode) {
|
|
3405
|
+
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
3406
|
+
if (key.name === 'escape') {
|
|
3407
|
+
// 📖 Cancel profile save — discard typed name
|
|
3408
|
+
state.profileSaveMode = false
|
|
3409
|
+
state.profileSaveBuffer = ''
|
|
3410
|
+
return
|
|
3411
|
+
}
|
|
3412
|
+
if (key.name === 'return') {
|
|
3413
|
+
// 📖 Confirm profile save — persist current TUI settings under typed name
|
|
3414
|
+
const name = state.profileSaveBuffer.trim()
|
|
3415
|
+
if (name.length > 0) {
|
|
3416
|
+
saveAsProfile(state.config, name, {
|
|
3417
|
+
tierFilter: TIER_CYCLE[tierFilterMode],
|
|
3418
|
+
sortColumn: state.sortColumn,
|
|
3419
|
+
sortAsc: state.sortDirection === 'asc',
|
|
3420
|
+
pingInterval: state.pingInterval,
|
|
3421
|
+
})
|
|
3422
|
+
setActiveProfile(state.config, name)
|
|
3423
|
+
state.activeProfile = name
|
|
3424
|
+
saveConfig(state.config)
|
|
3425
|
+
}
|
|
3426
|
+
state.profileSaveMode = false
|
|
3427
|
+
state.profileSaveBuffer = ''
|
|
3428
|
+
return
|
|
3429
|
+
}
|
|
3430
|
+
if (key.name === 'backspace') {
|
|
3431
|
+
state.profileSaveBuffer = state.profileSaveBuffer.slice(0, -1)
|
|
3432
|
+
return
|
|
3433
|
+
}
|
|
3434
|
+
// 📖 Append printable characters (str is the raw character typed)
|
|
3435
|
+
if (str && str.length === 1 && !key.ctrl && !key.meta) {
|
|
3436
|
+
state.profileSaveBuffer += str
|
|
3437
|
+
}
|
|
3438
|
+
return
|
|
3439
|
+
}
|
|
3440
|
+
|
|
2955
3441
|
// 📖 Help overlay: full keyboard navigation + key swallowing while overlay is open.
|
|
2956
3442
|
if (state.helpVisible) {
|
|
2957
3443
|
const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
|
|
@@ -2969,11 +3455,122 @@ async function main() {
|
|
|
2969
3455
|
return
|
|
2970
3456
|
}
|
|
2971
3457
|
|
|
3458
|
+
// 📖 Smart Recommend overlay: full keyboard handling while overlay is open.
|
|
3459
|
+
if (state.recommendOpen) {
|
|
3460
|
+
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
3461
|
+
|
|
3462
|
+
if (state.recommendPhase === 'questionnaire') {
|
|
3463
|
+
const questions = [
|
|
3464
|
+
{ options: Object.keys(TASK_TYPES), answerKey: 'taskType' },
|
|
3465
|
+
{ options: Object.keys(PRIORITY_TYPES), answerKey: 'priority' },
|
|
3466
|
+
{ options: Object.keys(CONTEXT_BUDGETS), answerKey: 'contextBudget' },
|
|
3467
|
+
]
|
|
3468
|
+
const q = questions[state.recommendQuestion]
|
|
3469
|
+
|
|
3470
|
+
if (key.name === 'escape') {
|
|
3471
|
+
// 📖 Cancel recommend — close overlay
|
|
3472
|
+
state.recommendOpen = false
|
|
3473
|
+
state.recommendPhase = 'questionnaire'
|
|
3474
|
+
state.recommendQuestion = 0
|
|
3475
|
+
state.recommendCursor = 0
|
|
3476
|
+
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
3477
|
+
return
|
|
3478
|
+
}
|
|
3479
|
+
if (key.name === 'up') {
|
|
3480
|
+
state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : q.options.length - 1
|
|
3481
|
+
return
|
|
3482
|
+
}
|
|
3483
|
+
if (key.name === 'down') {
|
|
3484
|
+
state.recommendCursor = state.recommendCursor < q.options.length - 1 ? state.recommendCursor + 1 : 0
|
|
3485
|
+
return
|
|
3486
|
+
}
|
|
3487
|
+
if (key.name === 'return') {
|
|
3488
|
+
// 📖 Record answer and advance to next question or start analysis
|
|
3489
|
+
state.recommendAnswers[q.answerKey] = q.options[state.recommendCursor]
|
|
3490
|
+
if (state.recommendQuestion < questions.length - 1) {
|
|
3491
|
+
state.recommendQuestion++
|
|
3492
|
+
state.recommendCursor = 0
|
|
3493
|
+
} else {
|
|
3494
|
+
// 📖 All questions answered — start analysis phase
|
|
3495
|
+
startRecommendAnalysis()
|
|
3496
|
+
}
|
|
3497
|
+
return
|
|
3498
|
+
}
|
|
3499
|
+
return // 📖 Swallow all other keys
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
if (state.recommendPhase === 'analyzing') {
|
|
3503
|
+
if (key.name === 'escape') {
|
|
3504
|
+
// 📖 Cancel analysis — stop timers, return to questionnaire
|
|
3505
|
+
stopRecommendAnalysis()
|
|
3506
|
+
state.recommendOpen = false
|
|
3507
|
+
state.recommendPhase = 'questionnaire'
|
|
3508
|
+
state.recommendQuestion = 0
|
|
3509
|
+
state.recommendCursor = 0
|
|
3510
|
+
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
3511
|
+
return
|
|
3512
|
+
}
|
|
3513
|
+
return // 📖 Swallow all keys during analysis (except Esc and Ctrl+C)
|
|
3514
|
+
}
|
|
3515
|
+
|
|
3516
|
+
if (state.recommendPhase === 'results') {
|
|
3517
|
+
if (key.name === 'escape') {
|
|
3518
|
+
// 📖 Close results — recommendations stay highlighted in main table
|
|
3519
|
+
state.recommendOpen = false
|
|
3520
|
+
return
|
|
3521
|
+
}
|
|
3522
|
+
if (key.name === 'q') {
|
|
3523
|
+
// 📖 Start a new search
|
|
3524
|
+
state.recommendPhase = 'questionnaire'
|
|
3525
|
+
state.recommendQuestion = 0
|
|
3526
|
+
state.recommendCursor = 0
|
|
3527
|
+
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
3528
|
+
state.recommendResults = []
|
|
3529
|
+
state.recommendScrollOffset = 0
|
|
3530
|
+
return
|
|
3531
|
+
}
|
|
3532
|
+
if (key.name === 'up') {
|
|
3533
|
+
const count = state.recommendResults.length
|
|
3534
|
+
if (count === 0) return
|
|
3535
|
+
state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : count - 1
|
|
3536
|
+
return
|
|
3537
|
+
}
|
|
3538
|
+
if (key.name === 'down') {
|
|
3539
|
+
const count = state.recommendResults.length
|
|
3540
|
+
if (count === 0) return
|
|
3541
|
+
state.recommendCursor = state.recommendCursor < count - 1 ? state.recommendCursor + 1 : 0
|
|
3542
|
+
return
|
|
3543
|
+
}
|
|
3544
|
+
if (key.name === 'return') {
|
|
3545
|
+
// 📖 Select the highlighted recommendation — close overlay, jump cursor to it
|
|
3546
|
+
const rec = state.recommendResults[state.recommendCursor]
|
|
3547
|
+
if (rec) {
|
|
3548
|
+
const recKey = toFavoriteKey(rec.result.providerKey, rec.result.modelId)
|
|
3549
|
+
state.recommendOpen = false
|
|
3550
|
+
// 📖 Jump to the recommended model in the main table
|
|
3551
|
+
const idx = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === recKey)
|
|
3552
|
+
if (idx >= 0) {
|
|
3553
|
+
state.cursor = idx
|
|
3554
|
+
adjustScrollOffset(state)
|
|
3555
|
+
}
|
|
3556
|
+
}
|
|
3557
|
+
return
|
|
3558
|
+
}
|
|
3559
|
+
return // 📖 Swallow all other keys
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
return // 📖 Catch-all swallow
|
|
3563
|
+
}
|
|
3564
|
+
|
|
2972
3565
|
// ─── Settings overlay keyboard handling ───────────────────────────────────
|
|
2973
3566
|
if (state.settingsOpen) {
|
|
2974
3567
|
const providerKeys = Object.keys(sources)
|
|
2975
3568
|
const telemetryRowIdx = providerKeys.length
|
|
2976
3569
|
const updateRowIdx = providerKeys.length + 1
|
|
3570
|
+
// 📖 Profile rows start after update row — one row per saved profile
|
|
3571
|
+
const savedProfiles = listProfiles(state.config)
|
|
3572
|
+
const profileStartIdx = updateRowIdx + 1
|
|
3573
|
+
const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
|
|
2977
3574
|
|
|
2978
3575
|
// 📖 Edit mode: capture typed characters for the API key
|
|
2979
3576
|
if (state.settingsEditMode) {
|
|
@@ -3040,7 +3637,7 @@ async function main() {
|
|
|
3040
3637
|
return
|
|
3041
3638
|
}
|
|
3042
3639
|
|
|
3043
|
-
if (key.name === 'down' && state.settingsCursor <
|
|
3640
|
+
if (key.name === 'down' && state.settingsCursor < maxRowIdx) {
|
|
3044
3641
|
state.settingsCursor++
|
|
3045
3642
|
return
|
|
3046
3643
|
}
|
|
@@ -3053,7 +3650,7 @@ async function main() {
|
|
|
3053
3650
|
|
|
3054
3651
|
if (key.name === 'pagedown') {
|
|
3055
3652
|
const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
|
|
3056
|
-
state.settingsCursor = Math.min(
|
|
3653
|
+
state.settingsCursor = Math.min(maxRowIdx, state.settingsCursor + pageStep)
|
|
3057
3654
|
return
|
|
3058
3655
|
}
|
|
3059
3656
|
|
|
@@ -3063,7 +3660,7 @@ async function main() {
|
|
|
3063
3660
|
}
|
|
3064
3661
|
|
|
3065
3662
|
if (key.name === 'end') {
|
|
3066
|
-
state.settingsCursor =
|
|
3663
|
+
state.settingsCursor = maxRowIdx
|
|
3067
3664
|
return
|
|
3068
3665
|
}
|
|
3069
3666
|
|
|
@@ -3084,6 +3681,33 @@ async function main() {
|
|
|
3084
3681
|
return
|
|
3085
3682
|
}
|
|
3086
3683
|
|
|
3684
|
+
// 📖 Profile row: Enter → load the selected profile (apply its settings live)
|
|
3685
|
+
if (state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
|
|
3686
|
+
const profileIdx = state.settingsCursor - profileStartIdx
|
|
3687
|
+
const profileName = savedProfiles[profileIdx]
|
|
3688
|
+
if (profileName) {
|
|
3689
|
+
const settings = loadProfile(state.config, profileName)
|
|
3690
|
+
if (settings) {
|
|
3691
|
+
state.sortColumn = settings.sortColumn || 'avg'
|
|
3692
|
+
state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
|
|
3693
|
+
state.pingInterval = settings.pingInterval || PING_INTERVAL
|
|
3694
|
+
if (settings.tierFilter) {
|
|
3695
|
+
const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
|
|
3696
|
+
if (tierIdx >= 0) tierFilterMode = tierIdx
|
|
3697
|
+
} else {
|
|
3698
|
+
tierFilterMode = 0
|
|
3699
|
+
}
|
|
3700
|
+
state.activeProfile = profileName
|
|
3701
|
+
syncFavoriteFlags(state.results, state.config)
|
|
3702
|
+
applyTierFilter()
|
|
3703
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
3704
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
3705
|
+
saveConfig(state.config)
|
|
3706
|
+
}
|
|
3707
|
+
}
|
|
3708
|
+
return
|
|
3709
|
+
}
|
|
3710
|
+
|
|
3087
3711
|
// 📖 Enter edit mode for the selected provider's key
|
|
3088
3712
|
const pk = providerKeys[state.settingsCursor]
|
|
3089
3713
|
state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
|
|
@@ -3100,6 +3724,8 @@ async function main() {
|
|
|
3100
3724
|
return
|
|
3101
3725
|
}
|
|
3102
3726
|
if (state.settingsCursor === updateRowIdx) return
|
|
3727
|
+
// 📖 Profile rows don't respond to Space
|
|
3728
|
+
if (state.settingsCursor >= profileStartIdx) return
|
|
3103
3729
|
|
|
3104
3730
|
// 📖 Toggle enabled/disabled for selected provider
|
|
3105
3731
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -3112,6 +3738,8 @@ async function main() {
|
|
|
3112
3738
|
|
|
3113
3739
|
if (key.name === 't') {
|
|
3114
3740
|
if (state.settingsCursor === telemetryRowIdx || state.settingsCursor === updateRowIdx) return
|
|
3741
|
+
// 📖 Profile rows don't respond to T (test key)
|
|
3742
|
+
if (state.settingsCursor >= profileStartIdx) return
|
|
3115
3743
|
|
|
3116
3744
|
// 📖 Test the selected provider's key (fires a real ping)
|
|
3117
3745
|
const pk = providerKeys[state.settingsCursor]
|
|
@@ -3124,12 +3752,34 @@ async function main() {
|
|
|
3124
3752
|
return
|
|
3125
3753
|
}
|
|
3126
3754
|
|
|
3755
|
+
// 📖 Backspace on a profile row → delete that profile
|
|
3756
|
+
if (key.name === 'backspace' && state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
|
|
3757
|
+
const profileIdx = state.settingsCursor - profileStartIdx
|
|
3758
|
+
const profileName = savedProfiles[profileIdx]
|
|
3759
|
+
if (profileName) {
|
|
3760
|
+
deleteProfile(state.config, profileName)
|
|
3761
|
+
// 📖 If the deleted profile was active, clear active state
|
|
3762
|
+
if (state.activeProfile === profileName) {
|
|
3763
|
+
setActiveProfile(state.config, null)
|
|
3764
|
+
state.activeProfile = null
|
|
3765
|
+
}
|
|
3766
|
+
saveConfig(state.config)
|
|
3767
|
+
// 📖 Re-clamp cursor after deletion (profile list just got shorter)
|
|
3768
|
+
const newProfiles = listProfiles(state.config)
|
|
3769
|
+
const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : updateRowIdx
|
|
3770
|
+
if (state.settingsCursor > newMaxRowIdx) {
|
|
3771
|
+
state.settingsCursor = Math.max(0, newMaxRowIdx)
|
|
3772
|
+
}
|
|
3773
|
+
}
|
|
3774
|
+
return
|
|
3775
|
+
}
|
|
3776
|
+
|
|
3127
3777
|
if (key.ctrl && key.name === 'c') { exit(0); return }
|
|
3128
3778
|
return // 📖 Swallow all other keys while settings is open
|
|
3129
3779
|
}
|
|
3130
3780
|
|
|
3131
3781
|
// 📖 P key: open settings screen
|
|
3132
|
-
if (key.name === 'p') {
|
|
3782
|
+
if (key.name === 'p' && !key.shift) {
|
|
3133
3783
|
state.settingsOpen = true
|
|
3134
3784
|
state.settingsCursor = 0
|
|
3135
3785
|
state.settingsEditMode = false
|
|
@@ -3138,6 +3788,77 @@ async function main() {
|
|
|
3138
3788
|
return
|
|
3139
3789
|
}
|
|
3140
3790
|
|
|
3791
|
+
// 📖 Q key: open Smart Recommend overlay
|
|
3792
|
+
if (key.name === 'q') {
|
|
3793
|
+
state.recommendOpen = true
|
|
3794
|
+
state.recommendPhase = 'questionnaire'
|
|
3795
|
+
state.recommendQuestion = 0
|
|
3796
|
+
state.recommendCursor = 0
|
|
3797
|
+
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
3798
|
+
state.recommendResults = []
|
|
3799
|
+
state.recommendScrollOffset = 0
|
|
3800
|
+
return
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
// 📖 Shift+P: cycle through profiles (or show profile picker)
|
|
3804
|
+
if (key.name === 'p' && key.shift) {
|
|
3805
|
+
const profiles = listProfiles(state.config)
|
|
3806
|
+
if (profiles.length === 0) {
|
|
3807
|
+
// 📖 No profiles saved — save current config as 'default' profile
|
|
3808
|
+
saveAsProfile(state.config, 'default', {
|
|
3809
|
+
tierFilter: TIER_CYCLE[tierFilterMode],
|
|
3810
|
+
sortColumn: state.sortColumn,
|
|
3811
|
+
sortAsc: state.sortDirection === 'asc',
|
|
3812
|
+
pingInterval: state.pingInterval,
|
|
3813
|
+
})
|
|
3814
|
+
setActiveProfile(state.config, 'default')
|
|
3815
|
+
state.activeProfile = 'default'
|
|
3816
|
+
saveConfig(state.config)
|
|
3817
|
+
} else {
|
|
3818
|
+
// 📖 Cycle to next profile (or back to null = raw config)
|
|
3819
|
+
const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
|
|
3820
|
+
const nextIdx = (currentIdx + 1) % (profiles.length + 1) // +1 for "no profile"
|
|
3821
|
+
if (nextIdx === profiles.length) {
|
|
3822
|
+
// 📖 Back to raw config (no profile)
|
|
3823
|
+
setActiveProfile(state.config, null)
|
|
3824
|
+
state.activeProfile = null
|
|
3825
|
+
saveConfig(state.config)
|
|
3826
|
+
} else {
|
|
3827
|
+
const nextProfile = profiles[nextIdx]
|
|
3828
|
+
const settings = loadProfile(state.config, nextProfile)
|
|
3829
|
+
if (settings) {
|
|
3830
|
+
// 📖 Apply profile's TUI settings to live state
|
|
3831
|
+
state.sortColumn = settings.sortColumn || 'avg'
|
|
3832
|
+
state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
|
|
3833
|
+
state.pingInterval = settings.pingInterval || PING_INTERVAL
|
|
3834
|
+
if (settings.tierFilter) {
|
|
3835
|
+
const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
|
|
3836
|
+
if (tierIdx >= 0) tierFilterMode = tierIdx
|
|
3837
|
+
} else {
|
|
3838
|
+
tierFilterMode = 0
|
|
3839
|
+
}
|
|
3840
|
+
state.activeProfile = nextProfile
|
|
3841
|
+
// 📖 Rebuild favorites from profile data
|
|
3842
|
+
syncFavoriteFlags(state.results, state.config)
|
|
3843
|
+
applyTierFilter()
|
|
3844
|
+
const visible = state.results.filter(r => !r.hidden)
|
|
3845
|
+
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
3846
|
+
state.cursor = 0
|
|
3847
|
+
state.scrollOffset = 0
|
|
3848
|
+
saveConfig(state.config)
|
|
3849
|
+
}
|
|
3850
|
+
}
|
|
3851
|
+
}
|
|
3852
|
+
return
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
// 📖 Shift+S: enter profile save mode — inline text prompt for typing a profile name
|
|
3856
|
+
if (key.name === 's' && key.shift) {
|
|
3857
|
+
state.profileSaveMode = true
|
|
3858
|
+
state.profileSaveBuffer = ''
|
|
3859
|
+
return
|
|
3860
|
+
}
|
|
3861
|
+
|
|
3141
3862
|
// 📖 Sorting keys: R=rank, Y=tier, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime
|
|
3142
3863
|
// 📖 T is reserved for tier filter cycling — tier sort moved to Y
|
|
3143
3864
|
// 📖 N is now reserved for origin filter cycling
|
|
@@ -3146,7 +3867,7 @@ async function main() {
|
|
|
3146
3867
|
'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
|
|
3147
3868
|
}
|
|
3148
3869
|
|
|
3149
|
-
if (sortKeys[key.name] && !key.ctrl) {
|
|
3870
|
+
if (sortKeys[key.name] && !key.ctrl && !key.shift) {
|
|
3150
3871
|
const col = sortKeys[key.name]
|
|
3151
3872
|
// 📖 Toggle direction if same column, otherwise reset to asc
|
|
3152
3873
|
if (state.sortColumn === col) {
|
|
@@ -3322,19 +4043,21 @@ async function main() {
|
|
|
3322
4043
|
|
|
3323
4044
|
process.stdin.on('keypress', onKeyPress)
|
|
3324
4045
|
|
|
3325
|
-
// 📖 Animation loop: render settings overlay OR main table
|
|
4046
|
+
// 📖 Animation loop: render settings overlay, recommend overlay, help overlay, OR main table
|
|
3326
4047
|
const ticker = setInterval(() => {
|
|
3327
4048
|
state.frame++
|
|
3328
4049
|
// 📖 Cache visible+sorted models each frame so Enter handler always matches the display
|
|
3329
|
-
if (!state.settingsOpen) {
|
|
4050
|
+
if (!state.settingsOpen && !state.recommendOpen) {
|
|
3330
4051
|
const visible = state.results.filter(r => !r.hidden)
|
|
3331
4052
|
state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
|
|
3332
4053
|
}
|
|
3333
4054
|
const content = state.settingsOpen
|
|
3334
4055
|
? renderSettings()
|
|
3335
|
-
: state.
|
|
3336
|
-
?
|
|
3337
|
-
:
|
|
4056
|
+
: state.recommendOpen
|
|
4057
|
+
? renderRecommend()
|
|
4058
|
+
: state.helpVisible
|
|
4059
|
+
? renderHelp()
|
|
4060
|
+
: renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer)
|
|
3338
4061
|
process.stdout.write(ALT_HOME + content)
|
|
3339
4062
|
}, Math.round(1000 / FPS))
|
|
3340
4063
|
|
|
@@ -3342,7 +4065,19 @@ async function main() {
|
|
|
3342
4065
|
const initialVisible = state.results.filter(r => !r.hidden)
|
|
3343
4066
|
state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
|
|
3344
4067
|
|
|
3345
|
-
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode))
|
|
4068
|
+
process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, tierFilterMode, state.scrollOffset, state.terminalRows, originFilterMode, state.activeProfile, state.profileSaveMode, state.profileSaveBuffer))
|
|
4069
|
+
|
|
4070
|
+
// 📖 If --recommend was passed, auto-open the Smart Recommend overlay on start
|
|
4071
|
+
if (cliArgs.recommendMode) {
|
|
4072
|
+
state.recommendOpen = true
|
|
4073
|
+
state.recommendPhase = 'questionnaire'
|
|
4074
|
+
state.recommendCursor = 0
|
|
4075
|
+
state.recommendQuestion = 0
|
|
4076
|
+
state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
|
|
4077
|
+
state.recommendProgress = 0
|
|
4078
|
+
state.recommendResults = []
|
|
4079
|
+
state.recommendScrollOffset = 0
|
|
4080
|
+
}
|
|
3346
4081
|
|
|
3347
4082
|
// ── Continuous ping loop — ping all models every N seconds forever ──────────
|
|
3348
4083
|
|