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.
@@ -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 2-second ping intervals (never stops)
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: 2 seconds (continuous monitoring mode)
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 = 3_000 // 📖 Ping all models every 3 seconds in continuous mode
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 = 6 // 📖 spacer, hints, spacer, credit+contributors, discord, spacer
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
- // 📖 Favorites are always pinned at the top and keep insertion order.
875
- // 📖 Non-favorites still use the active sort column/direction.
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
- const nonFavoriteRows = sortResults(results.filter((r) => !r.isFavorite), sortColumn, sortDirection)
881
- return [...favoriteRows, ...nonFavoriteRows]
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
- lines.push('')
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
- lines.push(chalk.dim(` ↑↓ Navigate • `) + actionHint + chalk.dim(` • F Favorite • R/Y/O/M/L/A/S/C/H/V/B/U Sort • T Tier • N Origin • W↓/X↑ (${intervalSec}s) • `) + chalk.rgb(255, 100, 50).bold('Z Mode') + chalk.dim(` • `) + chalk.yellow('P') + chalk.dim(` Settings • `) + chalk.rgb(0, 255, 80).bold('K Help'))
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: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
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: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
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 varies by platform
1539
- // 📖 Windows: %APPDATA%\opencode\opencode.json (or sometimes ~/.config/opencode)
1540
- // 📖 macOS/Linux: ~/.config/opencode/opencode.json
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
- async function spawnOpenCode(args, providerKey, fcmConfig) {
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', resolve)
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
- // 📖 Groq: built-in OpenCode provider -- needs provider block with apiKey in opencode.json.
1779
- // 📖 Cerebras: NOT built-in -- needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
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 analytics / Check-or-Install update • Space Toggle enabled • T Test key • U Check updates • Esc Close'))
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('Filter:')} ${chalk.yellow('N')}`)
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 < updateRowIdx) {
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(updateRowIdx, state.settingsCursor + pageStep)
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 = updateRowIdx
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 based on state
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.helpVisible
3336
- ? renderHelp()
3337
- : 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)
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