free-coding-models 0.1.66 β†’ 0.1.68

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
@@ -23,7 +23,7 @@
23
23
  * - Settings screen (P key) to manage API keys, provider toggles, analytics, and manual updates
24
24
  * - Favorites system: toggle with F, pin rows to top, persist between sessions
25
25
  * - Uptime percentage tracking (successful pings / total pings)
26
- * - Sortable columns (R/Y/O/M/L/A/S/N/H/V/U keys)
26
+ * - Sortable columns (R/Y/O/M/L/A/S/N/H/V/B/U keys)
27
27
  * - Tier filtering via T key (cycles S+β†’Sβ†’A+β†’Aβ†’A-β†’B+β†’Bβ†’Cβ†’All)
28
28
  *
29
29
  * β†’ Functions:
@@ -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, 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 = 2_000 // πŸ“– Ping all models every 2 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.
@@ -767,6 +771,47 @@ function stripAnsi(input) {
767
771
  return String(input).replace(/\x1b\[[0-9;]*m/g, '').replace(/\x1b\][^\x1b]*\x1b\\/g, '')
768
772
  }
769
773
 
774
+ // πŸ“– Calculate display width of a string in terminal columns.
775
+ // πŸ“– Emojis and other wide characters occupy 2 columns, variation selectors (U+FE0F) are zero-width.
776
+ // πŸ“– This avoids pulling in a full `string-width` dependency for a lightweight CLI tool.
777
+ function displayWidth(str) {
778
+ const plain = stripAnsi(String(str))
779
+ let w = 0
780
+ for (const ch of plain) {
781
+ const cp = ch.codePointAt(0)
782
+ // Zero-width: variation selectors (FE00-FE0F), zero-width joiner/non-joiner, combining marks
783
+ if ((cp >= 0xFE00 && cp <= 0xFE0F) || cp === 0x200D || cp === 0x200C || cp === 0x20E3) continue
784
+ // Wide: CJK, emoji (most above U+1F000), fullwidth forms
785
+ if (
786
+ cp > 0x1F000 || // emoji & symbols
787
+ (cp >= 0x2600 && cp <= 0x27BF) || // misc symbols, dingbats
788
+ (cp >= 0x2300 && cp <= 0x23FF) || // misc technical (⏳, ⏰, etc.)
789
+ (cp >= 0x2700 && cp <= 0x27BF) || // dingbats
790
+ (cp >= 0xFE10 && cp <= 0xFE19) || // vertical forms
791
+ (cp >= 0xFF01 && cp <= 0xFF60) || // fullwidth ASCII
792
+ (cp >= 0xFFE0 && cp <= 0xFFE6) || // fullwidth signs
793
+ (cp >= 0x4E00 && cp <= 0x9FFF) || // CJK unified
794
+ (cp >= 0x3000 && cp <= 0x303F) || // CJK symbols
795
+ (cp >= 0x2B50 && cp <= 0x2B55) || // stars, circles
796
+ cp === 0x2705 || cp === 0x2714 || cp === 0x2716 || // check/cross marks
797
+ cp === 0x26A0 // ⚠ warning sign
798
+ ) {
799
+ w += 2
800
+ } else {
801
+ w += 1
802
+ }
803
+ }
804
+ return w
805
+ }
806
+
807
+ // πŸ“– Left-pad (padEnd equivalent) using display width instead of string length.
808
+ // πŸ“– Ensures columns with emoji text align correctly in the terminal.
809
+ function padEndDisplay(str, width) {
810
+ const dw = displayWidth(str)
811
+ const need = Math.max(0, width - dw)
812
+ return str + ' '.repeat(need)
813
+ }
814
+
770
815
  // πŸ“– Tint overlay lines with a fixed dark panel width so the background is clearly visible.
771
816
  function tintOverlayLines(lines, bgColor) {
772
817
  return lines.map((line) => {
@@ -811,7 +856,7 @@ function sliceOverlayLines(lines, offset, terminalRows) {
811
856
  // πŸ“– Keep these constants in sync with renderTable() fixed shell lines.
812
857
  // πŸ“– If this drifts, model rows overflow and can push the title row out of view.
813
858
  const TABLE_HEADER_LINES = 4 // πŸ“– title, spacer, column headers, separator
814
- 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
815
860
  const TABLE_FIXED_LINES = TABLE_HEADER_LINES + TABLE_FOOTER_LINES
816
861
 
817
862
  // πŸ“– Computes the visible slice of model rows that fits in the terminal.
@@ -830,18 +875,27 @@ function calculateViewport(terminalRows, scrollOffset, totalModels) {
830
875
  return { startIdx: scrollOffset, endIdx, hasAbove, hasBelow }
831
876
  }
832
877
 
833
- // πŸ“– Favorites are always pinned at the top and keep insertion order.
834
- // πŸ“– 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.
835
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))
836
886
  const favoriteRows = results
837
- .filter((r) => r.isFavorite)
887
+ .filter((r) => r.isFavorite && !r.isRecommended)
838
888
  .sort((a, b) => a.favoriteRank - b.favoriteRank)
839
- const nonFavoriteRows = sortResults(results.filter((r) => !r.isFavorite), sortColumn, sortDirection)
840
- 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]
841
895
  }
842
896
 
843
897
  // πŸ“– renderTable: mode param controls footer hint text (opencode vs openclaw)
844
- 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 = '') {
845
899
  // πŸ“– Filter out hidden models for display
846
900
  const visibleResults = results.filter(r => !r.hidden)
847
901
 
@@ -893,6 +947,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
893
947
  }
894
948
  }
895
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
+
896
956
  // πŸ“– Column widths (generous spacing with margins)
897
957
  const W_RANK = 6
898
958
  const W_TIER = 6
@@ -904,13 +964,14 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
904
964
  const W_AVG = 11
905
965
  const W_STATUS = 18
906
966
  const W_VERDICT = 14
967
+ const W_STAB = 11
907
968
  const W_UPTIME = 6
908
969
 
909
970
  // πŸ“– Sort models using the shared helper
910
971
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
911
972
 
912
973
  const lines = [
913
- ` ${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} ` +
914
975
  chalk.greenBright(`βœ… ${up}`) + chalk.dim(' up ') +
915
976
  chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
916
977
  chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
@@ -933,6 +994,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
933
994
  const avgH = sortColumn === 'avg' ? dir + ' Avg Ping' : 'Avg Ping'
934
995
  const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
935
996
  const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
997
+ const stabH = sortColumn === 'stability' ? dir + ' Stability' : 'Stability'
936
998
  const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
937
999
 
938
1000
  // πŸ“– Helper to colorize first letter for keyboard shortcuts
@@ -948,10 +1010,14 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
948
1010
  // πŸ“– Now colorize after padding is calculated on plain text
949
1011
  const rankH_c = colorFirst(rankH, W_RANK)
950
1012
  const tierH_c = colorFirst('Tier', W_TIER)
951
- const originLabel = 'Origin(N)'
1013
+ const originLabel = 'Origin'
952
1014
  const originH_c = sortColumn === 'origin'
953
1015
  ? chalk.bold.cyan(originLabel.padEnd(W_SOURCE))
954
- : (originFilterMode > 0 ? chalk.bold.rgb(100, 200, 255)(originLabel.padEnd(W_SOURCE)) : colorFirst(originLabel, W_SOURCE))
1016
+ : (originFilterMode > 0 ? chalk.bold.rgb(100, 200, 255)(originLabel.padEnd(W_SOURCE)) : (() => {
1017
+ // πŸ“– Custom colorization for Origin: highlight 'N' (the filter key) at the end
1018
+ const padding = ' '.repeat(Math.max(0, W_SOURCE - originLabel.length))
1019
+ return chalk.dim('Origi') + chalk.yellow('N') + chalk.dim(padding)
1020
+ })())
955
1021
  const modelH_c = colorFirst(modelH, W_MODEL)
956
1022
  const sweH_c = sortColumn === 'swe' ? chalk.bold.cyan(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
957
1023
  const ctxH_c = sortColumn === 'ctx' ? chalk.bold.cyan(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
@@ -959,10 +1025,16 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
959
1025
  const avgH_c = sortColumn === 'avg' ? chalk.bold.cyan(avgH.padEnd(W_AVG)) : colorFirst('Avg Ping', W_AVG)
960
1026
  const healthH_c = sortColumn === 'condition' ? chalk.bold.cyan(healthH.padEnd(W_STATUS)) : colorFirst('Health', W_STATUS)
961
1027
  const verdictH_c = sortColumn === 'verdict' ? chalk.bold.cyan(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
962
- const uptimeH_c = sortColumn === 'uptime' ? chalk.bold.cyan(uptimeH.padStart(W_UPTIME)) : colorFirst(uptimeH, W_UPTIME, chalk.green)
1028
+ // πŸ“– Custom colorization for Stability: highlight 'B' (the sort key) since 'S' is taken by SWE
1029
+ const stabH_c = sortColumn === 'stability' ? chalk.bold.cyan(stabH.padEnd(W_STAB)) : (() => {
1030
+ const plain = 'Stability'
1031
+ const padding = ' '.repeat(Math.max(0, W_STAB - plain.length))
1032
+ return chalk.dim('Sta') + chalk.white.bold('B') + chalk.dim('ility' + padding)
1033
+ })()
1034
+ const uptimeH_c = sortColumn === 'uptime' ? chalk.bold.cyan(uptimeH.padEnd(W_UPTIME)) : colorFirst(uptimeH, W_UPTIME, chalk.green)
963
1035
 
964
- // πŸ“– Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Up%)
965
- lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + uptimeH_c)
1036
+ // πŸ“– Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%)
1037
+ lines.push(' ' + rankH_c + ' ' + tierH_c + ' ' + sweH_c + ' ' + ctxH_c + ' ' + modelH_c + ' ' + originH_c + ' ' + pingH_c + ' ' + avgH_c + ' ' + healthH_c + ' ' + verdictH_c + ' ' + stabH_c + ' ' + uptimeH_c)
966
1038
 
967
1039
  // πŸ“– Separator line
968
1040
  lines.push(
@@ -977,6 +1049,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
977
1049
  chalk.dim('─'.repeat(W_AVG)) + ' ' +
978
1050
  chalk.dim('─'.repeat(W_STATUS)) + ' ' +
979
1051
  chalk.dim('─'.repeat(W_VERDICT)) + ' ' +
1052
+ chalk.dim('─'.repeat(W_STAB)) + ' ' +
980
1053
  chalk.dim('─'.repeat(W_UPTIME))
981
1054
  )
982
1055
 
@@ -999,16 +1072,32 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
999
1072
  // πŸ“– Show provider name from sources map (NIM / Groq / Cerebras)
1000
1073
  const providerName = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
1001
1074
  const source = chalk.green(providerName.padEnd(W_SOURCE))
1002
- // πŸ“– Favorites get a leading star in Model column.
1003
- const favoritePrefix = r.isFavorite ? '⭐ ' : ''
1004
- const nameWidth = Math.max(0, W_MODEL - favoritePrefix.length)
1075
+ // πŸ“– Favorites: always reserve 2 display columns at the start of Model column.
1076
+ // πŸ“– 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites β€” keeps alignment stable.
1077
+ const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
1078
+ const prefixDisplayWidth = 2
1079
+ const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
1005
1080
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
1006
1081
  const sweScore = r.sweScore ?? 'β€”'
1007
- const sweCell = sweScore !== 'β€”' && parseFloat(sweScore) >= 50
1008
- ? chalk.greenBright(sweScore.padEnd(W_SWE))
1009
- : sweScore !== 'β€”' && parseFloat(sweScore) >= 30
1010
- ? chalk.yellow(sweScore.padEnd(W_SWE))
1011
- : chalk.dim(sweScore.padEnd(W_SWE))
1082
+ // πŸ“– SWE% colorized on the same gradient as Tier:
1083
+ // β‰₯70% bright neon green (S+), β‰₯60% green (S), β‰₯50% yellow-green (A+),
1084
+ // β‰₯40% yellow (A), β‰₯35% amber (A-), β‰₯30% orange-red (B+),
1085
+ // β‰₯20% red (B), <20% dark red (C), 'β€”' dim
1086
+ let sweCell
1087
+ if (sweScore === 'β€”') {
1088
+ sweCell = chalk.dim(sweScore.padEnd(W_SWE))
1089
+ } else {
1090
+ const sweVal = parseFloat(sweScore)
1091
+ const swePadded = sweScore.padEnd(W_SWE)
1092
+ if (sweVal >= 70) sweCell = chalk.bold.rgb(0, 255, 80)(swePadded)
1093
+ else if (sweVal >= 60) sweCell = chalk.bold.rgb(80, 220, 0)(swePadded)
1094
+ else if (sweVal >= 50) sweCell = chalk.bold.rgb(170, 210, 0)(swePadded)
1095
+ else if (sweVal >= 40) sweCell = chalk.rgb(240, 190, 0)(swePadded)
1096
+ else if (sweVal >= 35) sweCell = chalk.rgb(255, 130, 0)(swePadded)
1097
+ else if (sweVal >= 30) sweCell = chalk.rgb(255, 70, 0)(swePadded)
1098
+ else if (sweVal >= 20) sweCell = chalk.rgb(210, 20, 0)(swePadded)
1099
+ else sweCell = chalk.rgb(140, 0, 0)(swePadded)
1100
+ }
1012
1101
 
1013
1102
  // πŸ“– Context window column - colorized by size (larger = better)
1014
1103
  const ctxRaw = r.ctx ?? 'β€”'
@@ -1023,7 +1112,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1023
1112
  const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
1024
1113
  let pingCell
1025
1114
  if (!latestPing) {
1026
- pingCell = chalk.dim('β€”'.padEnd(W_PING))
1115
+ pingCell = chalk.dim('β€”β€”β€”'.padEnd(W_PING))
1027
1116
  } else if (latestPing.code === '200') {
1028
1117
  // πŸ“– Success - show response time
1029
1118
  const str = String(latestPing.ms).padEnd(W_PING)
@@ -1032,8 +1121,8 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1032
1121
  // πŸ“– 401 = no API key but server IS reachable β€” still show latency in dim
1033
1122
  pingCell = chalk.dim(String(latestPing.ms).padEnd(W_PING))
1034
1123
  } else {
1035
- // πŸ“– Error or timeout - show "β€”" (error code is already in Status column)
1036
- pingCell = chalk.dim('β€”'.padEnd(W_PING))
1124
+ // πŸ“– Error or timeout - show "β€”β€”β€”" (error code is already in Status column)
1125
+ pingCell = chalk.dim('β€”β€”β€”'.padEnd(W_PING))
1037
1126
  }
1038
1127
 
1039
1128
  // πŸ“– Avg ping (just number, no "ms")
@@ -1043,7 +1132,7 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1043
1132
  const str = String(avg).padEnd(W_AVG)
1044
1133
  avgCell = avg < 500 ? chalk.greenBright(str) : avg < 1500 ? chalk.yellow(str) : chalk.red(str)
1045
1134
  } else {
1046
- avgCell = chalk.dim('β€”'.padEnd(W_AVG))
1135
+ avgCell = chalk.dim('β€”β€”β€”'.padEnd(W_AVG))
1047
1136
  }
1048
1137
 
1049
1138
  // πŸ“– Status column - build plain text with emoji, pad, then colorize
@@ -1080,64 +1169,102 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1080
1169
  statusText = '?'
1081
1170
  statusColor = (s) => chalk.dim(s)
1082
1171
  }
1083
- const status = statusColor(statusText.padEnd(W_STATUS))
1172
+ const status = statusColor(padEndDisplay(statusText, W_STATUS))
1084
1173
 
1085
- // πŸ“– Verdict column - build plain text with emoji, pad, then colorize
1086
- const wasUpBefore = r.pings.length > 0 && r.pings.some(p => p.code === '200')
1174
+ // πŸ“– Verdict column - use getVerdict() for stability-aware verdicts, then render with emoji
1175
+ const verdict = getVerdict(r)
1087
1176
  let verdictText, verdictColor
1088
- if (r.httpCode === '429') {
1089
- verdictText = 'πŸ”₯ Overloaded'
1090
- verdictColor = (s) => chalk.yellow.bold(s)
1091
- } else if ((r.status === 'timeout' || r.status === 'down') && wasUpBefore) {
1092
- verdictText = '⚠️ Unstable'
1093
- verdictColor = (s) => chalk.magenta(s)
1094
- } else if (r.status === 'timeout' || r.status === 'down') {
1095
- verdictText = 'πŸ‘» Not Active'
1096
- verdictColor = (s) => chalk.dim(s)
1097
- } else if (avg === Infinity) {
1098
- verdictText = '⏳ Pending'
1099
- verdictColor = (s) => chalk.dim(s)
1100
- } else if (avg < 400) {
1101
- verdictText = 'πŸš€ Perfect'
1102
- verdictColor = (s) => chalk.greenBright(s)
1103
- } else if (avg < 1000) {
1104
- verdictText = 'βœ… Normal'
1105
- verdictColor = (s) => chalk.cyan(s)
1106
- } else if (avg < 3000) {
1107
- verdictText = '🐒 Slow'
1108
- verdictColor = (s) => chalk.yellow(s)
1109
- } else if (avg < 5000) {
1110
- verdictText = '🐌 Very Slow'
1111
- verdictColor = (s) => chalk.red(s)
1177
+ // πŸ“– Verdict colors follow the same greenβ†’red gradient as TIER_COLOR / SWE%
1178
+ switch (verdict) {
1179
+ case 'Perfect':
1180
+ verdictText = 'Perfect πŸš€'
1181
+ verdictColor = (s) => chalk.bold.rgb(0, 255, 180)(s) // bright cyan-green β€” stands out from Normal
1182
+ break
1183
+ case 'Normal':
1184
+ verdictText = 'Normal βœ…'
1185
+ verdictColor = (s) => chalk.bold.rgb(140, 200, 0)(s) // lime-yellow β€” clearly warmer than Perfect
1186
+ break
1187
+ case 'Spiky':
1188
+ verdictText = 'Spiky πŸ“ˆ'
1189
+ verdictColor = (s) => chalk.bold.rgb(170, 210, 0)(s) // A+ yellow-green
1190
+ break
1191
+ case 'Slow':
1192
+ verdictText = 'Slow 🐒'
1193
+ verdictColor = (s) => chalk.bold.rgb(255, 130, 0)(s) // A- amber
1194
+ break
1195
+ case 'Very Slow':
1196
+ verdictText = 'Very Slow 🐌'
1197
+ verdictColor = (s) => chalk.bold.rgb(255, 70, 0)(s) // B+ orange-red
1198
+ break
1199
+ case 'Overloaded':
1200
+ verdictText = 'Overloaded πŸ”₯'
1201
+ verdictColor = (s) => chalk.bold.rgb(210, 20, 0)(s) // B red
1202
+ break
1203
+ case 'Unstable':
1204
+ verdictText = 'Unstable ⚠️'
1205
+ verdictColor = (s) => chalk.bold.rgb(175, 10, 0)(s) // between B and C
1206
+ break
1207
+ case 'Not Active':
1208
+ verdictText = 'Not Active πŸ‘»'
1209
+ verdictColor = (s) => chalk.dim(s)
1210
+ break
1211
+ case 'Pending':
1212
+ verdictText = 'Pending ⏳'
1213
+ verdictColor = (s) => chalk.dim(s)
1214
+ break
1215
+ default:
1216
+ verdictText = 'Unusable πŸ’€'
1217
+ verdictColor = (s) => chalk.bold.rgb(140, 0, 0)(s) // C dark red
1218
+ break
1219
+ }
1220
+ // πŸ“– Use padEndDisplay to account for emoji display width (2 cols each) so all rows align
1221
+ const speedCell = verdictColor(padEndDisplay(verdictText, W_VERDICT))
1222
+
1223
+ // πŸ“– Stability column - composite score (0–100) from p95 + jitter + spikes + uptime
1224
+ // πŸ“– Left-aligned to sit flush under the column header
1225
+ const stabScore = getStabilityScore(r)
1226
+ let stabCell
1227
+ if (stabScore < 0) {
1228
+ stabCell = chalk.dim('β€”β€”β€”'.padEnd(W_STAB))
1229
+ } else if (stabScore >= 80) {
1230
+ stabCell = chalk.greenBright(String(stabScore).padEnd(W_STAB))
1231
+ } else if (stabScore >= 60) {
1232
+ stabCell = chalk.cyan(String(stabScore).padEnd(W_STAB))
1233
+ } else if (stabScore >= 40) {
1234
+ stabCell = chalk.yellow(String(stabScore).padEnd(W_STAB))
1112
1235
  } else {
1113
- verdictText = 'πŸ’€ Unusable'
1114
- verdictColor = (s) => chalk.red.bold(s)
1236
+ stabCell = chalk.red(String(stabScore).padEnd(W_STAB))
1115
1237
  }
1116
- const speedCell = verdictColor(verdictText.padEnd(W_VERDICT))
1117
1238
 
1118
1239
  // πŸ“– Uptime column - percentage of successful pings
1240
+ // πŸ“– Left-aligned to sit flush under the column header
1119
1241
  const uptimePercent = getUptime(r)
1120
1242
  const uptimeStr = uptimePercent + '%'
1121
1243
  let uptimeCell
1122
1244
  if (uptimePercent >= 90) {
1123
- uptimeCell = chalk.greenBright(uptimeStr.padStart(W_UPTIME))
1245
+ uptimeCell = chalk.greenBright(uptimeStr.padEnd(W_UPTIME))
1124
1246
  } else if (uptimePercent >= 70) {
1125
- uptimeCell = chalk.yellow(uptimeStr.padStart(W_UPTIME))
1247
+ uptimeCell = chalk.yellow(uptimeStr.padEnd(W_UPTIME))
1126
1248
  } else if (uptimePercent >= 50) {
1127
- uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.padStart(W_UPTIME)) // orange
1249
+ uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.padEnd(W_UPTIME)) // orange
1128
1250
  } else {
1129
- uptimeCell = chalk.red(uptimeStr.padStart(W_UPTIME))
1251
+ uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
1130
1252
  }
1131
1253
 
1132
- // πŸ“– Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Up%)
1133
- const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + name + ' ' + source + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + uptimeCell
1254
+ // πŸ“– When cursor is on this row, render Model and Origin in bright white for readability
1255
+ const nameCell = isCursor ? chalk.white.bold(favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)) : name
1256
+ const sourceCell = isCursor ? chalk.white.bold(providerName.padEnd(W_SOURCE)) : source
1134
1257
 
1135
- if (isCursor && r.isFavorite) {
1136
- lines.push(chalk.bgRgb(120, 60, 0)(row))
1137
- } else if (isCursor) {
1138
- lines.push(chalk.bgRgb(139, 0, 139)(row))
1258
+ // πŸ“– Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Origin, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%)
1259
+ const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell
1260
+
1261
+ if (isCursor) {
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))
1139
1266
  } else if (r.isFavorite) {
1140
- lines.push(chalk.bgRgb(90, 45, 0)(row))
1267
+ lines.push(chalk.bgRgb(35, 20, 0)(row))
1141
1268
  } else {
1142
1269
  lines.push(row)
1143
1270
  }
@@ -1147,7 +1274,12 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1147
1274
  lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
1148
1275
  }
1149
1276
 
1150
- 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
+ }
1151
1283
  const intervalSec = Math.round(pingInterval / 1000)
1152
1284
 
1153
1285
  // πŸ“– Footer hints adapt based on active mode
@@ -1156,19 +1288,27 @@ function renderTable(results, pendingPings, frame, cursor = null, sortColumn = '
1156
1288
  : mode === 'opencode-desktop'
1157
1289
  ? chalk.rgb(0, 200, 255)('Enterβ†’OpenDesktop')
1158
1290
  : chalk.rgb(0, 200, 255)('Enterβ†’OpenCode')
1159
- lines.push(chalk.dim(` ↑↓ Navigate β€’ `) + actionHint + chalk.dim(` β€’ F Favorite β€’ R/Y/O/M/L/A/S/C/H/V/U Sort β€’ T Tier β€’ N Origin β€’ W↓/X↑ (${intervalSec}s) β€’ Z Mode β€’ `) + chalk.yellow('P') + chalk.dim(` Settings β€’ `) + chalk.bgGreenBright.black.bold(' K Help ') + chalk.dim(` β€’ Ctrl+C Exit`))
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`))
1160
1295
  lines.push('')
1161
1296
  lines.push(
1162
1297
  chalk.rgb(255, 150, 200)(' Made with πŸ’– & β˜• by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
1163
1298
  chalk.dim(' β€’ ') +
1164
1299
  '⭐ ' +
1165
- '\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\' +
1300
+ chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
1166
1301
  chalk.dim(' β€’ ') +
1167
1302
  '🀝 ' +
1168
- '\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\'
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(' β€’ ') +
1305
+ 'πŸ’¬ ' +
1306
+ chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/5MbTnDC3Md\x1b\\Discord\x1b]8;;\x1b\\') +
1307
+ chalk.dim(' β†’ ') +
1308
+ chalk.rgb(200, 150, 255)('https://discord.gg/5MbTnDC3Md') +
1309
+ chalk.dim(' β€’ ') +
1310
+ chalk.dim('Ctrl+C Exit')
1169
1311
  )
1170
- // πŸ“– Discord invite + BETA warning β€” always visible at the bottom of the TUI
1171
- lines.push(' πŸ’¬ ' + chalk.cyanBright('\x1b]8;;https://discord.gg/5MbTnDC3Md\x1b\\Join our Discord\x1b]8;;\x1b\\') + chalk.dim(' β†’ ') + chalk.cyanBright('https://discord.gg/5MbTnDC3Md') + chalk.dim(' β€’ ') + chalk.yellow('⚠ BETA TUI') + chalk.dim(' β€” might crash or have problems'))
1172
1312
  lines.push('')
1173
1313
  // πŸ“– Append \x1b[K (erase to EOL) to each line so leftover chars from previous
1174
1314
  // πŸ“– frames are cleared. Then pad with blank cleared lines to fill the terminal,
@@ -1196,6 +1336,9 @@ function resolveCloudflareUrl(url) {
1196
1336
  }
1197
1337
 
1198
1338
  function buildPingRequest(apiKey, modelId, providerKey, url) {
1339
+ // πŸ“– ZAI models are stored as "zai/glm-..." in sources.js but the API expects just "glm-..."
1340
+ const apiModelId = providerKey === 'zai' ? modelId.replace(/^zai\//, '') : modelId
1341
+
1199
1342
  if (providerKey === 'replicate') {
1200
1343
  // πŸ“– Replicate uses /v1/predictions with a different payload than OpenAI chat-completions.
1201
1344
  const replicateHeaders = { 'Content-Type': 'application/json', Prefer: 'wait=4' }
@@ -1214,7 +1357,7 @@ function buildPingRequest(apiKey, modelId, providerKey, url) {
1214
1357
  return {
1215
1358
  url: resolveCloudflareUrl(url),
1216
1359
  headers,
1217
- body: { model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
1360
+ body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
1218
1361
  }
1219
1362
  }
1220
1363
 
@@ -1229,7 +1372,7 @@ function buildPingRequest(apiKey, modelId, providerKey, url) {
1229
1372
  return {
1230
1373
  url,
1231
1374
  headers,
1232
- body: { model: modelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
1375
+ body: { model: apiModelId, messages: [{ role: 'user', content: 'hi' }], max_tokens: 1 },
1233
1376
  }
1234
1377
  }
1235
1378
 
@@ -1277,6 +1420,8 @@ const OPENCODE_MODEL_MAP = {
1277
1420
  }
1278
1421
 
1279
1422
  function getOpenCodeModelId(providerKey, modelId) {
1423
+ // πŸ“– ZAI models stored as "zai/glm-..." but OpenCode expects just "glm-..."
1424
+ if (providerKey === 'zai') return modelId.replace(/^zai\//, '')
1280
1425
  return OPENCODE_MODEL_MAP[providerKey]?.[modelId] || modelId
1281
1426
  }
1282
1427
 
@@ -1299,6 +1444,7 @@ const ENV_VAR_NAMES = {
1299
1444
  together: 'TOGETHER_API_KEY',
1300
1445
  cloudflare: 'CLOUDFLARE_API_TOKEN',
1301
1446
  perplexity: 'PERPLEXITY_API_KEY',
1447
+ zai: 'ZAI_API_KEY',
1302
1448
  }
1303
1449
 
1304
1450
  // πŸ“– Provider metadata used by the setup wizard and Settings details panel.
@@ -1423,17 +1569,18 @@ const PROVIDER_METADATA = {
1423
1569
  signupHint: 'Generate API key (billing may be required)',
1424
1570
  rateLimits: 'Tiered limits by spend (default ~50 RPM)',
1425
1571
  },
1572
+ zai: {
1573
+ label: 'ZAI (z.ai)',
1574
+ color: chalk.rgb(0, 150, 255),
1575
+ signupUrl: 'https://z.ai',
1576
+ signupHint: 'Sign up and generate an API key',
1577
+ rateLimits: 'Free tier (generous quota)',
1578
+ },
1426
1579
  }
1427
1580
 
1428
- // πŸ“– OpenCode config location varies by platform
1429
- // πŸ“– Windows: %APPDATA%\opencode\opencode.json (or sometimes ~/.config/opencode)
1430
- // πŸ“– macOS/Linux: ~/.config/opencode/opencode.json
1431
- const OPENCODE_CONFIG = isWindows
1432
- ? join(homedir(), 'AppData', 'Roaming', 'opencode', 'opencode.json')
1433
- : join(homedir(), '.config', 'opencode', 'opencode.json')
1434
-
1435
- // πŸ“– Fallback to .config on Windows if AppData doesn't exist
1436
- const OPENCODE_CONFIG_FALLBACK = join(homedir(), '.config', 'opencode', 'opencode.json')
1581
+ // πŸ“– OpenCode config location: ~/.config/opencode/opencode.json on ALL platforms.
1582
+ // πŸ“– OpenCode uses xdg-basedir which resolves to %USERPROFILE%\.config on Windows.
1583
+ const OPENCODE_CONFIG = join(homedir(), '.config', 'opencode', 'opencode.json')
1437
1584
  const OPENCODE_PORT_RANGE_START = 4096
1438
1585
  const OPENCODE_PORT_RANGE_END = 5096
1439
1586
 
@@ -1475,8 +1622,6 @@ async function resolveOpenCodeTmuxPort() {
1475
1622
  }
1476
1623
 
1477
1624
  function getOpenCodeConfigPath() {
1478
- if (existsSync(OPENCODE_CONFIG)) return OPENCODE_CONFIG
1479
- if (isWindows && existsSync(OPENCODE_CONFIG_FALLBACK)) return OPENCODE_CONFIG_FALLBACK
1480
1625
  return OPENCODE_CONFIG
1481
1626
  }
1482
1627
 
@@ -1519,14 +1664,68 @@ function checkNvidiaNimConfig() {
1519
1664
  // πŸ“– Resolves the actual API key from config/env and passes it as an env var
1520
1665
  // πŸ“– to the child process so OpenCode's {env:GROQ_API_KEY} references work
1521
1666
  // πŸ“– even when the key is only in ~/.free-coding-models.json (not in shell env).
1522
- async function spawnOpenCode(args, providerKey, fcmConfig) {
1667
+ // πŸ“– createZaiProxy: Localhost reverse proxy that bridges ZAI's non-standard API paths
1668
+ // πŸ“– to OpenCode's expected /v1/* OpenAI-compatible format.
1669
+ // πŸ“– OpenCode's local provider calls GET /v1/models for discovery and POST /v1/chat/completions
1670
+ // πŸ“– for inference. ZAI's API lives at /api/coding/paas/v4/* instead β€” this proxy rewrites.
1671
+ // πŸ“– Returns { server, port } β€” caller must server.close() when done.
1672
+ async function createZaiProxy(apiKey) {
1673
+ const server = createHttpServer((req, res) => {
1674
+ let targetPath = req.url
1675
+ // πŸ“– Rewrite /v1/* β†’ /api/coding/paas/v4/*
1676
+ if (targetPath.startsWith('/v1/')) {
1677
+ targetPath = '/api/coding/paas/v4/' + targetPath.slice(4)
1678
+ } else if (targetPath.startsWith('/v1')) {
1679
+ targetPath = '/api/coding/paas/v4' + targetPath.slice(3)
1680
+ } else {
1681
+ // πŸ“– Non /v1 paths (e.g. /api/v0/ health checks) β€” reject
1682
+ res.writeHead(404)
1683
+ res.end()
1684
+ return
1685
+ }
1686
+ const headers = { ...req.headers, host: 'api.z.ai' }
1687
+ if (apiKey) headers.authorization = `Bearer ${apiKey}`
1688
+ // πŸ“– Remove transfer-encoding to avoid chunked encoding issues with https.request
1689
+ delete headers['transfer-encoding']
1690
+ const proxyReq = httpsRequest({
1691
+ hostname: 'api.z.ai',
1692
+ port: 443,
1693
+ path: targetPath,
1694
+ method: req.method,
1695
+ headers,
1696
+ }, (proxyRes) => {
1697
+ res.writeHead(proxyRes.statusCode, proxyRes.headers)
1698
+ proxyRes.pipe(res)
1699
+ })
1700
+ proxyReq.on('error', () => { res.writeHead(502); res.end() })
1701
+ req.pipe(proxyReq)
1702
+ })
1703
+ await new Promise(r => server.listen(0, '127.0.0.1', r))
1704
+ return { server, port: server.address().port }
1705
+ }
1706
+
1707
+ async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = null) {
1523
1708
  const envVarName = ENV_VAR_NAMES[providerKey]
1524
1709
  const resolvedKey = getApiKey(fcmConfig, providerKey)
1525
1710
  const childEnv = { ...process.env }
1711
+ // πŸ“– Suppress MaxListenersExceededWarning from @modelcontextprotocol/sdk
1712
+ // πŸ“– when 7+ MCP servers cause drain listener count to exceed default 10
1713
+ childEnv.NODE_NO_WARNINGS = '1'
1526
1714
  const finalArgs = [...args]
1527
1715
  const hasExplicitPortArg = finalArgs.includes('--port')
1528
1716
  if (envVarName && resolvedKey) childEnv[envVarName] = resolvedKey
1529
1717
 
1718
+ // πŸ“– ZAI proxy: OpenCode's Go binary doesn't know about ZAI as a provider.
1719
+ // πŸ“– We spin up a localhost proxy that rewrites /v1/* β†’ /api/coding/paas/v4/*
1720
+ // πŸ“– and register ZAI as a custom openai-compatible provider in opencode.json.
1721
+ // πŸ“– If startOpenCode already started the proxy, reuse it (existingZaiProxy).
1722
+ let zaiProxy = existingZaiProxy
1723
+ if (providerKey === 'zai' && resolvedKey && !zaiProxy) {
1724
+ const { server, port } = await createZaiProxy(resolvedKey)
1725
+ zaiProxy = server
1726
+ console.log(chalk.dim(` πŸ”€ ZAI proxy listening on port ${port} (rewrites /v1/* β†’ ZAI API)`))
1727
+ }
1728
+
1530
1729
  // πŸ“– In tmux, OpenCode sub-agents need a listening port to open extra panes.
1531
1730
  // πŸ“– We auto-pick one if the user did not provide --port explicitly.
1532
1731
  if (process.env.TMUX && !hasExplicitPortArg) {
@@ -1554,8 +1753,22 @@ async function spawnOpenCode(args, providerKey, fcmConfig) {
1554
1753
  })
1555
1754
 
1556
1755
  return new Promise((resolve, reject) => {
1557
- child.on('exit', resolve)
1756
+ child.on('exit', (code) => {
1757
+ if (zaiProxy) zaiProxy.close()
1758
+ // πŸ“– ZAI cleanup: remove the ephemeral proxy provider from opencode.json
1759
+ // πŸ“– so a stale baseURL doesn't cause "Model zai/… is not valid" on next launch
1760
+ if (providerKey === 'zai') {
1761
+ try {
1762
+ const cfg = loadOpenCodeConfig()
1763
+ if (cfg.provider?.zai) delete cfg.provider.zai
1764
+ if (typeof cfg.model === 'string' && cfg.model.startsWith('zai/')) delete cfg.model
1765
+ saveOpenCodeConfig(cfg)
1766
+ } catch { /* best-effort cleanup */ }
1767
+ }
1768
+ resolve(code)
1769
+ })
1558
1770
  child.on('error', (err) => {
1771
+ if (zaiProxy) zaiProxy.close()
1559
1772
  if (err.code === 'ENOENT') {
1560
1773
  console.error(chalk.red('\n X Could not find "opencode" -- is it installed and in your PATH?'))
1561
1774
  console.error(chalk.dim(' Install: npm i -g opencode or see https://opencode.ai'))
@@ -1665,8 +1878,72 @@ After installation, you can use: opencode --model ${modelRef}`
1665
1878
  return
1666
1879
  }
1667
1880
 
1668
- // πŸ“– Groq: built-in OpenCode provider -- needs provider block with apiKey in opencode.json.
1669
- // πŸ“– Cerebras: NOT built-in -- needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
1881
+ // πŸ“– ZAI: OpenCode's Go binary has no built-in ZAI provider.
1882
+ // πŸ“– We start a localhost proxy that rewrites /v1/* β†’ /api/coding/paas/v4/*
1883
+ // πŸ“– and register ZAI as a custom openai-compatible provider pointing to the proxy.
1884
+ // πŸ“– This gives OpenCode a standard provider/model format (zai/glm-5) it understands.
1885
+ if (providerKey === 'zai') {
1886
+ const resolvedKey = getApiKey(fcmConfig, providerKey)
1887
+ if (!resolvedKey) {
1888
+ console.log(chalk.yellow(' ⚠ ZAI API key not found. Set ZAI_API_KEY environment variable.'))
1889
+ console.log()
1890
+ return
1891
+ }
1892
+
1893
+ // πŸ“– Start proxy FIRST to get the port for config
1894
+ const { server: zaiProxyServer, port: zaiProxyPort } = await createZaiProxy(resolvedKey)
1895
+ console.log(chalk.dim(` πŸ”€ ZAI proxy listening on port ${zaiProxyPort} (rewrites /v1/* β†’ ZAI API)`))
1896
+
1897
+ console.log(chalk.green(` πŸš€ Setting ${chalk.bold(model.label)} as default…`))
1898
+ console.log(chalk.dim(` Model: ${modelRef}`))
1899
+ console.log()
1900
+
1901
+ const config = loadOpenCodeConfig()
1902
+ const backupPath = `${getOpenCodeConfigPath()}.backup-${Date.now()}`
1903
+
1904
+ if (existsSync(getOpenCodeConfigPath())) {
1905
+ copyFileSync(getOpenCodeConfigPath(), backupPath)
1906
+ console.log(chalk.dim(` πŸ’Ύ Backup: ${backupPath}`))
1907
+ }
1908
+
1909
+ // πŸ“– Register ZAI as an openai-compatible provider pointing to our localhost proxy
1910
+ // πŸ“– apiKey is required by @ai-sdk/openai-compatible SDK β€” the proxy handles real auth internally
1911
+ if (!config.provider) config.provider = {}
1912
+ config.provider.zai = {
1913
+ npm: '@ai-sdk/openai-compatible',
1914
+ name: 'ZAI',
1915
+ options: {
1916
+ baseURL: `http://127.0.0.1:${zaiProxyPort}/v1`,
1917
+ apiKey: 'zai-proxy',
1918
+ },
1919
+ models: {}
1920
+ }
1921
+ config.provider.zai.models[ocModelId] = { name: model.label }
1922
+ config.model = modelRef
1923
+
1924
+ saveOpenCodeConfig(config)
1925
+
1926
+ const savedConfig = loadOpenCodeConfig()
1927
+ console.log(chalk.dim(` πŸ“ Config saved to: ${getOpenCodeConfigPath()}`))
1928
+ console.log(chalk.dim(` πŸ“ Default model in config: ${savedConfig.model || 'NOT SET'}`))
1929
+ console.log()
1930
+
1931
+ if (savedConfig.model === config.model) {
1932
+ console.log(chalk.green(` βœ“ Default model set to: ${modelRef}`))
1933
+ } else {
1934
+ console.log(chalk.yellow(` ⚠ Config might not have been saved correctly`))
1935
+ }
1936
+ console.log()
1937
+ console.log(chalk.dim(' Starting OpenCode…'))
1938
+ console.log()
1939
+
1940
+ // πŸ“– Pass existing proxy to spawnOpenCode so it doesn't start a second one
1941
+ await spawnOpenCode(['--model', modelRef], providerKey, fcmConfig, zaiProxyServer)
1942
+ return
1943
+ }
1944
+
1945
+ // πŸ“– Groq: built-in OpenCode provider β€” needs provider block with apiKey in opencode.json.
1946
+ // πŸ“– Cerebras: NOT built-in β€” needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
1670
1947
  // πŸ“– Both need the model registered in provider.<key>.models so OpenCode can find it.
1671
1948
  console.log(chalk.green(` πŸš€ Setting ${chalk.bold(model.label)} as default…`))
1672
1949
  console.log(chalk.dim(` Model: ${modelRef}`))
@@ -1982,6 +2259,16 @@ ${isWindows ? 'set NVIDIA_API_KEY=your_key_here' : 'export NVIDIA_API_KEY=your_k
1982
2259
  return
1983
2260
  }
1984
2261
 
2262
+ // πŸ“– ZAI: Desktop mode can't use the localhost proxy (Desktop is a standalone app).
2263
+ // πŸ“– Direct the user to use OpenCode CLI mode instead, which supports ZAI via proxy.
2264
+ if (providerKey === 'zai') {
2265
+ console.log(chalk.yellow(' ⚠ ZAI models are supported in OpenCode CLI mode only (not Desktop).'))
2266
+ console.log(chalk.dim(' Reason: ZAI requires a localhost proxy that only works with the CLI spawn.'))
2267
+ console.log(chalk.dim(' Use OpenCode CLI mode (default) to launch ZAI models.'))
2268
+ console.log()
2269
+ return
2270
+ }
2271
+
1985
2272
  // πŸ“– Groq: built-in OpenCode provider β€” needs provider block with apiKey in opencode.json.
1986
2273
  // πŸ“– Cerebras: NOT built-in β€” needs @ai-sdk/openai-compatible + baseURL, like NVIDIA.
1987
2274
  // πŸ“– Both need the model registered in provider.<key>.models so OpenCode can find it.
@@ -2344,6 +2631,16 @@ async function main() {
2344
2631
  ensureTelemetryConfig(config)
2345
2632
  ensureFavoritesConfig(config)
2346
2633
 
2634
+ // πŸ“– If --profile <name> was passed, load that profile into the live config
2635
+ if (cliArgs.profileName) {
2636
+ const profileSettings = loadProfile(config, cliArgs.profileName)
2637
+ if (!profileSettings) {
2638
+ console.error(chalk.red(` Unknown profile "${cliArgs.profileName}". Available: ${listProfiles(config).join(', ') || '(none)'}`))
2639
+ process.exit(1)
2640
+ }
2641
+ saveConfig(config)
2642
+ }
2643
+
2347
2644
  // πŸ“– Check if any provider has a key β€” if not, run the first-time setup wizard
2348
2645
  const hasAnyKey = Object.keys(sources).some(pk => !!getApiKey(config, pk))
2349
2646
 
@@ -2487,6 +2784,22 @@ async function main() {
2487
2784
  helpVisible: false, // πŸ“– Whether the help overlay (K key) is active
2488
2785
  settingsScrollOffset: 0, // πŸ“– Vertical scroll offset for Settings overlay viewport
2489
2786
  helpScrollOffset: 0, // πŸ“– Vertical scroll offset for Help overlay viewport
2787
+ // πŸ“– Smart Recommend overlay state (Q key opens it)
2788
+ recommendOpen: false, // πŸ“– Whether the recommend overlay is active
2789
+ recommendPhase: 'questionnaire', // πŸ“– 'questionnaire'|'analyzing'|'results' β€” current phase
2790
+ recommendCursor: 0, // πŸ“– Selected question option (0-based index within current question)
2791
+ recommendQuestion: 0, // πŸ“– Which question we're on (0=task, 1=priority, 2=context)
2792
+ recommendAnswers: { taskType: null, priority: null, contextBudget: null }, // πŸ“– User's answers
2793
+ recommendProgress: 0, // πŸ“– Analysis progress percentage (0–100)
2794
+ recommendResults: [], // πŸ“– Top N recommendations from getTopRecommendations()
2795
+ recommendScrollOffset: 0, // πŸ“– Vertical scroll offset for Recommend overlay viewport
2796
+ recommendAnalysisTimer: null, // πŸ“– setInterval handle for the 10s analysis phase
2797
+ recommendPingTimer: null, // πŸ“– setInterval handle for 2 pings/sec during analysis
2798
+ recommendedKeys: new Set(), // πŸ“– Set of "providerKey/modelId" for recommended models (shown in main table)
2799
+ // πŸ“– Config Profiles state
2800
+ activeProfile: getActiveProfileName(config), // πŸ“– Currently loaded profile name (or null)
2801
+ profileSaveMode: false, // πŸ“– Whether the inline "Save profile" name input is active
2802
+ profileSaveBuffer: '', // πŸ“– Typed characters for the profile name being saved
2490
2803
  }
2491
2804
 
2492
2805
  // πŸ“– Re-clamp viewport on terminal resize
@@ -2652,11 +2965,39 @@ async function main() {
2652
2965
  lines.push(chalk.red(` ${state.settingsUpdateError}`))
2653
2966
  }
2654
2967
 
2968
+ // πŸ“– Profiles section β€” list saved profiles with active indicator + delete support
2969
+ const savedProfiles = listProfiles(state.config)
2970
+ const profileStartIdx = updateRowIdx + 1
2971
+ const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
2972
+
2973
+ lines.push('')
2974
+ lines.push(` ${chalk.bold('πŸ“‹ Profiles')} ${chalk.dim(savedProfiles.length > 0 ? `(${savedProfiles.length} saved)` : '(none β€” press Shift+S in main view to save)')}`)
2975
+ lines.push(` ${chalk.dim(' ' + '─'.repeat(112))}`)
2976
+ lines.push('')
2977
+
2978
+ if (savedProfiles.length === 0) {
2979
+ lines.push(chalk.dim(' No saved profiles. Press Shift+S in the main table to save your current settings as a profile.'))
2980
+ } else {
2981
+ for (let i = 0; i < savedProfiles.length; i++) {
2982
+ const pName = savedProfiles[i]
2983
+ const rowIdx = profileStartIdx + i
2984
+ const isCursor = state.settingsCursor === rowIdx
2985
+ const isActive = state.activeProfile === pName
2986
+ const activeBadge = isActive ? chalk.greenBright(' βœ… active') : ''
2987
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
2988
+ const profileLabel = chalk.rgb(200, 150, 255).bold(pName.padEnd(30))
2989
+ const deleteHint = isCursor ? chalk.dim(' Enter→Load ‒ Backspace→Delete') : ''
2990
+ const row = `${bullet}${profileLabel}${activeBadge}${deleteHint}`
2991
+ cursorLineByRow[rowIdx] = lines.length
2992
+ lines.push(isCursor ? chalk.bgRgb(40, 20, 60)(row) : row)
2993
+ }
2994
+ }
2995
+
2655
2996
  lines.push('')
2656
2997
  if (state.settingsEditMode) {
2657
2998
  lines.push(chalk.dim(' Type API key β€’ Enter Save β€’ Esc Cancel'))
2658
2999
  } else {
2659
- 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'))
3000
+ lines.push(chalk.dim(' ↑↓ Navigate β€’ Enter Edit key / Toggle / Load profile β€’ Space Toggle β€’ T Test key β€’ U Updates β€’ ⌫ Delete profile β€’ Esc Close'))
2660
3001
  }
2661
3002
  lines.push('')
2662
3003
 
@@ -2684,27 +3025,62 @@ async function main() {
2684
3025
  const lines = []
2685
3026
  lines.push('')
2686
3027
  lines.push(` ${chalk.bold('❓ Keyboard Shortcuts')} ${chalk.dim('β€” ↑↓ / PgUp / PgDn / Home / End scroll β€’ K or Esc close')}`)
3028
+ lines.push('')
3029
+ lines.push(` ${chalk.bold('Columns')}`)
3030
+ lines.push('')
3031
+ lines.push(` ${chalk.cyan('Rank')} SWE-bench rank (1 = best coding score) ${chalk.dim('Sort:')} ${chalk.yellow('R')}`)
3032
+ lines.push(` ${chalk.dim('Quick glance at which model is objectively the best coder right now.')}`)
3033
+ lines.push('')
3034
+ 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')}`)
3035
+ lines.push(` ${chalk.dim('Skip the noise β€” S/S+ models solve real GitHub issues, C models are for light tasks.')}`)
3036
+ lines.push('')
3037
+ lines.push(` ${chalk.cyan('SWE%')} SWE-bench score β€” coding ability benchmark (color-coded) ${chalk.dim('Sort:')} ${chalk.yellow('S')}`)
3038
+ lines.push(` ${chalk.dim('The raw number behind the tier. Higher = better at writing, fixing, and refactoring code.')}`)
3039
+ lines.push('')
3040
+ lines.push(` ${chalk.cyan('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('C')}`)
3041
+ lines.push(` ${chalk.dim('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
3042
+ lines.push('')
3043
+ lines.push(` ${chalk.cyan('Model')} Model name (⭐ = favorited, pinned at top) ${chalk.dim('Sort:')} ${chalk.yellow('M')} ${chalk.dim('Favorite:')} ${chalk.yellow('F')}`)
3044
+ lines.push(` ${chalk.dim('Star the ones you like β€” they stay pinned at the top across restarts.')}`)
3045
+ lines.push('')
3046
+ lines.push(` ${chalk.cyan('Origin')} Provider source (NIM, Groq, Cerebras, etc.) ${chalk.dim('Sort:')} ${chalk.yellow('O')} ${chalk.dim('Cycle:')} ${chalk.yellow('N')}`)
3047
+ lines.push(` ${chalk.dim('Same model on different providers can have very different speed and uptime.')}`)
3048
+ lines.push('')
3049
+ lines.push(` ${chalk.cyan('Latest')} Most recent ping response time (ms) ${chalk.dim('Sort:')} ${chalk.yellow('L')}`)
3050
+ lines.push(` ${chalk.dim('Shows how fast the server is responding right now β€” useful to catch live slowdowns.')}`)
3051
+ lines.push('')
3052
+ lines.push(` ${chalk.cyan('Avg Ping')} Average response time across all successful pings (ms) ${chalk.dim('Sort:')} ${chalk.yellow('A')}`)
3053
+ lines.push(` ${chalk.dim('The long-term truth. Ignore lucky one-off pings, this tells you real everyday speed.')}`)
3054
+ lines.push('')
3055
+ lines.push(` ${chalk.cyan('Health')} Live status: βœ… UP / πŸ”₯ 429 / ⏳ TIMEOUT / ❌ ERR / πŸ”‘ NO KEY ${chalk.dim('Sort:')} ${chalk.yellow('H')}`)
3056
+ lines.push(` ${chalk.dim('Tells you instantly if a model is reachable or down β€” no guesswork needed.')}`)
3057
+ lines.push('')
3058
+ lines.push(` ${chalk.cyan('Verdict')} Overall assessment: Perfect / Normal / Spiky / Slow / Overloaded ${chalk.dim('Sort:')} ${chalk.yellow('V')}`)
3059
+ lines.push(` ${chalk.dim('One-word summary so you don\'t have to cross-check speed, health, and stability yourself.')}`)
3060
+ lines.push('')
3061
+ lines.push(` ${chalk.cyan('Stability')} Composite 0–100 score: p95 + jitter + spike rate + uptime ${chalk.dim('Sort:')} ${chalk.yellow('B')}`)
3062
+ lines.push(` ${chalk.dim('A fast model that randomly freezes is worse than a steady one. This catches that.')}`)
3063
+ lines.push('')
3064
+ lines.push(` ${chalk.cyan('Up%')} Uptime β€” ratio of successful pings to total pings ${chalk.dim('Sort:')} ${chalk.yellow('U')}`)
3065
+ lines.push(` ${chalk.dim('If a model only works half the time, you\'ll waste time retrying. Higher = more reliable.')}`)
3066
+
2687
3067
  lines.push('')
2688
3068
  lines.push(` ${chalk.bold('Main TUI')}`)
2689
3069
  lines.push(` ${chalk.bold('Navigation')}`)
2690
3070
  lines.push(` ${chalk.yellow('↑↓')} Navigate rows`)
2691
3071
  lines.push(` ${chalk.yellow('Enter')} Select model and launch`)
2692
3072
  lines.push('')
2693
- lines.push(` ${chalk.bold('Sorting')}`)
2694
- lines.push(` ${chalk.yellow('R')} Rank ${chalk.yellow('Y')} Tier ${chalk.yellow('O')} Origin ${chalk.yellow('M')} Model`)
2695
- lines.push(` ${chalk.yellow('L')} Latest ping ${chalk.yellow('A')} Avg ping ${chalk.yellow('S')} SWE-bench score`)
2696
- lines.push(` ${chalk.yellow('C')} Context window ${chalk.yellow('H')} Health ${chalk.yellow('V')} Verdict ${chalk.yellow('U')} Uptime`)
2697
- lines.push('')
2698
- lines.push(` ${chalk.bold('Filters')}`)
2699
- lines.push(` ${chalk.yellow('T')} Cycle tier filter ${chalk.dim('(All β†’ S+ β†’ S β†’ A+ β†’ A β†’ A- β†’ B+ β†’ B β†’ C β†’ All)')}`)
2700
- lines.push(` ${chalk.yellow('N')} Cycle origin filter ${chalk.dim('(All β†’ NIM β†’ Groq β†’ Cerebras β†’ ... each provider β†’ All)')}`)
2701
- lines.push('')
2702
3073
  lines.push(` ${chalk.bold('Controls')}`)
2703
3074
  lines.push(` ${chalk.yellow('W')} Decrease ping interval (faster)`)
2704
3075
  lines.push(` ${chalk.yellow('X')} Increase ping interval (slower)`)
2705
3076
  lines.push(` ${chalk.yellow('Z')} Cycle launch mode ${chalk.dim('(OpenCode CLI β†’ OpenCode Desktop β†’ OpenClaw)')}`)
2706
3077
  lines.push(` ${chalk.yellow('F')} Toggle favorite on selected row ${chalk.dim('(⭐ pinned at top, persisted)')}`)
3078
+ lines.push(` ${chalk.yellow('Q')} Smart Recommend ${chalk.dim('(🎯 find the best model for your task β€” questionnaire + live analysis)')}`)
2707
3079
  lines.push(` ${chalk.yellow('P')} Open settings ${chalk.dim('(manage API keys, provider toggles, analytics, manual update)')}`)
3080
+ lines.push(` ${chalk.yellow('Shift+P')} Cycle config profile ${chalk.dim('(switch between saved profiles live)')}`)
3081
+ lines.push(` ${chalk.yellow('Shift+S')} Save current config as a named profile ${chalk.dim('(inline prompt β€” type name + Enter)')}`)
3082
+ lines.push(` ${chalk.dim('Profiles store: favorites, sort, tier filter, ping interval, API keys.')}`)
3083
+ lines.push(` ${chalk.dim('Use --profile <name> to load a profile on startup.')}`)
2708
3084
  lines.push(` ${chalk.yellow('K')} / ${chalk.yellow('Esc')} Show/hide this help`)
2709
3085
  lines.push(` ${chalk.yellow('Ctrl+C')} Exit`)
2710
3086
  lines.push('')
@@ -2727,6 +3103,8 @@ async function main() {
2727
3103
  lines.push(` ${chalk.cyan('free-coding-models --fiable')} ${chalk.dim('10s reliability analysis')}`)
2728
3104
  lines.push(` ${chalk.cyan('free-coding-models --tier S|A|B|C')} ${chalk.dim('Filter by tier letter')}`)
2729
3105
  lines.push(` ${chalk.cyan('free-coding-models --no-telemetry')} ${chalk.dim('Disable telemetry for this run')}`)
3106
+ lines.push(` ${chalk.cyan('free-coding-models --recommend')} ${chalk.dim('Auto-open Smart Recommend on start')}`)
3107
+ lines.push(` ${chalk.cyan('free-coding-models --profile <name>')} ${chalk.dim('Load a saved config profile')}`)
2730
3108
  lines.push(` ${chalk.dim('Flags can be combined: --openclaw --tier S')}`)
2731
3109
  lines.push('')
2732
3110
  // πŸ“– Help overlay can be longer than viewport, so keep a dedicated scroll offset.
@@ -2737,6 +3115,211 @@ async function main() {
2737
3115
  return cleared.join('\n')
2738
3116
  }
2739
3117
 
3118
+ // ─── Smart Recommend overlay renderer ─────────────────────────────────────
3119
+ // πŸ“– renderRecommend: Draw the Smart Recommend overlay with 3 phases:
3120
+ // 1. 'questionnaire' β€” ask 3 questions (task type, priority, context budget)
3121
+ // 2. 'analyzing' β€” loading screen with progress bar (10s, 2 pings/sec)
3122
+ // 3. 'results' β€” show Top 3 recommendations with scores
3123
+ function renderRecommend() {
3124
+ const EL = '\x1b[K'
3125
+ const lines = []
3126
+
3127
+ lines.push('')
3128
+ lines.push(` ${chalk.bold('🎯 Smart Recommend')} ${chalk.dim('β€” find the best model for your task')}`)
3129
+ lines.push('')
3130
+
3131
+ if (state.recommendPhase === 'questionnaire') {
3132
+ // πŸ“– Question definitions β€” each has a title, options array, and answer key
3133
+ const questions = [
3134
+ {
3135
+ title: 'What are you working on?',
3136
+ options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: val.label })),
3137
+ answerKey: 'taskType',
3138
+ },
3139
+ {
3140
+ title: 'What matters most?',
3141
+ options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: val.label })),
3142
+ answerKey: 'priority',
3143
+ },
3144
+ {
3145
+ title: 'How big is your context?',
3146
+ options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: val.label })),
3147
+ answerKey: 'contextBudget',
3148
+ },
3149
+ ]
3150
+
3151
+ const q = questions[state.recommendQuestion]
3152
+ const qNum = state.recommendQuestion + 1
3153
+ const qTotal = questions.length
3154
+
3155
+ // πŸ“– Progress breadcrumbs showing answered questions
3156
+ let breadcrumbs = ''
3157
+ for (let i = 0; i < questions.length; i++) {
3158
+ const answered = state.recommendAnswers[questions[i].answerKey]
3159
+ if (i < state.recommendQuestion && answered) {
3160
+ const answeredLabel = questions[i].options.find(o => o.key === answered)?.label || answered
3161
+ breadcrumbs += chalk.greenBright(` βœ“ ${questions[i].title} ${chalk.bold(answeredLabel)}`) + '\n'
3162
+ }
3163
+ }
3164
+ if (breadcrumbs) {
3165
+ lines.push(breadcrumbs.trimEnd())
3166
+ lines.push('')
3167
+ }
3168
+
3169
+ lines.push(` ${chalk.bold(`Question ${qNum}/${qTotal}:`)} ${chalk.cyan(q.title)}`)
3170
+ lines.push('')
3171
+
3172
+ for (let i = 0; i < q.options.length; i++) {
3173
+ const opt = q.options[i]
3174
+ const isCursor = i === state.recommendCursor
3175
+ const bullet = isCursor ? chalk.bold.cyan(' ❯ ') : chalk.dim(' ')
3176
+ const label = isCursor ? chalk.bold.white(opt.label) : chalk.white(opt.label)
3177
+ lines.push(`${bullet}${label}`)
3178
+ }
3179
+
3180
+ lines.push('')
3181
+ lines.push(chalk.dim(' ↑↓ navigate β€’ Enter select β€’ Esc cancel'))
3182
+
3183
+ } else if (state.recommendPhase === 'analyzing') {
3184
+ // πŸ“– Loading screen with progress bar
3185
+ const pct = Math.min(100, Math.round(state.recommendProgress))
3186
+ const barWidth = 40
3187
+ const filled = Math.round(barWidth * pct / 100)
3188
+ const empty = barWidth - filled
3189
+ const bar = chalk.greenBright('β–ˆ'.repeat(filled)) + chalk.dim('β–‘'.repeat(empty))
3190
+
3191
+ lines.push(` ${chalk.bold('Analyzing models...')}`)
3192
+ lines.push('')
3193
+ lines.push(` ${bar} ${chalk.bold(String(pct) + '%')}`)
3194
+ lines.push('')
3195
+
3196
+ // πŸ“– Show what we're doing
3197
+ const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || 'β€”'
3198
+ const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || 'β€”'
3199
+ const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || 'β€”'
3200
+ lines.push(chalk.dim(` Task: ${taskLabel} β€’ Priority: ${prioLabel} β€’ Context: ${ctxLabel}`))
3201
+ lines.push('')
3202
+
3203
+ // πŸ“– Spinning indicator
3204
+ const spinIdx = state.frame % FRAMES.length
3205
+ lines.push(` ${chalk.yellow(FRAMES[spinIdx])} Pinging models at 2 pings/sec to gather fresh latency data...`)
3206
+ lines.push('')
3207
+ lines.push(chalk.dim(' Esc to cancel'))
3208
+
3209
+ } else if (state.recommendPhase === 'results') {
3210
+ // πŸ“– Show Top 3 results with detailed info
3211
+ const taskLabel = TASK_TYPES[state.recommendAnswers.taskType]?.label || 'β€”'
3212
+ const prioLabel = PRIORITY_TYPES[state.recommendAnswers.priority]?.label || 'β€”'
3213
+ const ctxLabel = CONTEXT_BUDGETS[state.recommendAnswers.contextBudget]?.label || 'β€”'
3214
+ lines.push(chalk.dim(` Task: ${taskLabel} β€’ Priority: ${prioLabel} β€’ Context: ${ctxLabel}`))
3215
+ lines.push('')
3216
+
3217
+ if (state.recommendResults.length === 0) {
3218
+ lines.push(` ${chalk.yellow('No models could be scored. Try different criteria or wait for more pings.')}`)
3219
+ } else {
3220
+ lines.push(` ${chalk.bold('Top Recommendations:')}`)
3221
+ lines.push('')
3222
+
3223
+ for (let i = 0; i < state.recommendResults.length; i++) {
3224
+ const rec = state.recommendResults[i]
3225
+ const r = rec.result
3226
+ const medal = i === 0 ? 'πŸ₯‡' : i === 1 ? 'πŸ₯ˆ' : 'πŸ₯‰'
3227
+ const providerName = sources[r.providerKey]?.name ?? r.providerKey
3228
+ const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
3229
+ const avg = getAvg(r)
3230
+ const avgStr = avg === Infinity ? 'β€”' : Math.round(avg) + 'ms'
3231
+ const sweStr = r.sweScore ?? 'β€”'
3232
+ const ctxStr = r.ctx ?? 'β€”'
3233
+ const stability = getStabilityScore(r)
3234
+ const stabStr = stability === -1 ? 'β€”' : String(stability)
3235
+
3236
+ const isCursor = i === state.recommendCursor
3237
+ const highlight = isCursor ? chalk.bgRgb(20, 50, 25) : (s => s)
3238
+
3239
+ lines.push(highlight(` ${medal} ${chalk.bold('#' + (i + 1))} ${chalk.bold.white(r.label)} ${chalk.dim('(' + providerName + ')')}`))
3240
+ 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)}`))
3241
+ lines.push('')
3242
+ }
3243
+ }
3244
+
3245
+ lines.push('')
3246
+ lines.push(` ${chalk.dim('These models are now')} ${chalk.greenBright('highlighted')} ${chalk.dim('and')} 🎯 ${chalk.dim('pinned in the main table.')}`)
3247
+ lines.push('')
3248
+ lines.push(chalk.dim(' ↑↓ navigate β€’ Enter select & close β€’ Esc close β€’ Q new search'))
3249
+ }
3250
+
3251
+ lines.push('')
3252
+ const { visible, offset } = sliceOverlayLines(lines, state.recommendScrollOffset, state.terminalRows)
3253
+ state.recommendScrollOffset = offset
3254
+ const tintedLines = tintOverlayLines(visible, RECOMMEND_OVERLAY_BG)
3255
+ const cleared2 = tintedLines.map(l => l + EL)
3256
+ return cleared2.join('\n')
3257
+ }
3258
+
3259
+ // ─── Smart Recommend: analysis phase controller ────────────────────────────
3260
+ // πŸ“– startRecommendAnalysis: begins the 10-second analysis phase.
3261
+ // πŸ“– Pings a random subset of visible models at 2 pings/sec while advancing progress.
3262
+ // πŸ“– After 10 seconds, computes recommendations and transitions to results phase.
3263
+ function startRecommendAnalysis() {
3264
+ state.recommendPhase = 'analyzing'
3265
+ state.recommendProgress = 0
3266
+ state.recommendResults = []
3267
+
3268
+ const startTime = Date.now()
3269
+ const ANALYSIS_DURATION = 10_000 // πŸ“– 10 seconds
3270
+ const PING_RATE = 500 // πŸ“– 2 pings per second (every 500ms)
3271
+
3272
+ // πŸ“– Progress updater β€” runs every 200ms to update the progress bar
3273
+ state.recommendAnalysisTimer = setInterval(() => {
3274
+ const elapsed = Date.now() - startTime
3275
+ state.recommendProgress = Math.min(100, (elapsed / ANALYSIS_DURATION) * 100)
3276
+
3277
+ if (elapsed >= ANALYSIS_DURATION) {
3278
+ // πŸ“– Analysis complete β€” compute recommendations
3279
+ clearInterval(state.recommendAnalysisTimer)
3280
+ clearInterval(state.recommendPingTimer)
3281
+ state.recommendAnalysisTimer = null
3282
+ state.recommendPingTimer = null
3283
+
3284
+ const recs = getTopRecommendations(
3285
+ state.results,
3286
+ state.recommendAnswers.taskType,
3287
+ state.recommendAnswers.priority,
3288
+ state.recommendAnswers.contextBudget,
3289
+ 3
3290
+ )
3291
+ state.recommendResults = recs
3292
+ state.recommendPhase = 'results'
3293
+ state.recommendCursor = 0
3294
+
3295
+ // πŸ“– Mark recommended models so the main table can highlight them
3296
+ state.recommendedKeys = new Set(recs.map(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId)))
3297
+ // πŸ“– Tag each result object so sortResultsWithPinnedFavorites can pin them
3298
+ state.results.forEach(r => {
3299
+ const key = toFavoriteKey(r.providerKey, r.modelId)
3300
+ const rec = recs.find(rec => toFavoriteKey(rec.result.providerKey, rec.result.modelId) === key)
3301
+ r.isRecommended = !!rec
3302
+ r.recommendScore = rec ? rec.score : 0
3303
+ })
3304
+ }
3305
+ }, 200)
3306
+
3307
+ // πŸ“– Targeted pinging β€” ping random visible models at 2/sec for fresh data
3308
+ state.recommendPingTimer = setInterval(() => {
3309
+ const visible = state.results.filter(r => !r.hidden && r.status !== 'noauth')
3310
+ if (visible.length === 0) return
3311
+ // πŸ“– Pick a random model to ping β€” spreads load across all models over 10s
3312
+ const target = visible[Math.floor(Math.random() * visible.length)]
3313
+ pingModel(target).catch(() => {})
3314
+ }, PING_RATE)
3315
+ }
3316
+
3317
+ // πŸ“– stopRecommendAnalysis: cleanup timers if user cancels during analysis
3318
+ function stopRecommendAnalysis() {
3319
+ if (state.recommendAnalysisTimer) { clearInterval(state.recommendAnalysisTimer); state.recommendAnalysisTimer = null }
3320
+ if (state.recommendPingTimer) { clearInterval(state.recommendPingTimer); state.recommendPingTimer = null }
3321
+ }
3322
+
2740
3323
  // ─── Settings key test helper ───────────────────────────────────────────────
2741
3324
  // πŸ“– Fires a single ping to the selected provider to verify the API key works.
2742
3325
  async function testProviderKey(providerKey) {
@@ -2808,6 +3391,45 @@ async function main() {
2808
3391
  const onKeyPress = async (str, key) => {
2809
3392
  if (!key) return
2810
3393
 
3394
+ // πŸ“– Profile save mode: intercept ALL keys while inline name input is active.
3395
+ // πŸ“– Enter β†’ save, Esc β†’ cancel, Backspace β†’ delete char, printable β†’ append to buffer.
3396
+ if (state.profileSaveMode) {
3397
+ if (key.ctrl && key.name === 'c') { exit(0); return }
3398
+ if (key.name === 'escape') {
3399
+ // πŸ“– Cancel profile save β€” discard typed name
3400
+ state.profileSaveMode = false
3401
+ state.profileSaveBuffer = ''
3402
+ return
3403
+ }
3404
+ if (key.name === 'return') {
3405
+ // πŸ“– Confirm profile save β€” persist current TUI settings under typed name
3406
+ const name = state.profileSaveBuffer.trim()
3407
+ if (name.length > 0) {
3408
+ saveAsProfile(state.config, name, {
3409
+ tierFilter: TIER_CYCLE[tierFilterMode],
3410
+ sortColumn: state.sortColumn,
3411
+ sortAsc: state.sortDirection === 'asc',
3412
+ pingInterval: state.pingInterval,
3413
+ })
3414
+ setActiveProfile(state.config, name)
3415
+ state.activeProfile = name
3416
+ saveConfig(state.config)
3417
+ }
3418
+ state.profileSaveMode = false
3419
+ state.profileSaveBuffer = ''
3420
+ return
3421
+ }
3422
+ if (key.name === 'backspace') {
3423
+ state.profileSaveBuffer = state.profileSaveBuffer.slice(0, -1)
3424
+ return
3425
+ }
3426
+ // πŸ“– Append printable characters (str is the raw character typed)
3427
+ if (str && str.length === 1 && !key.ctrl && !key.meta) {
3428
+ state.profileSaveBuffer += str
3429
+ }
3430
+ return
3431
+ }
3432
+
2811
3433
  // πŸ“– Help overlay: full keyboard navigation + key swallowing while overlay is open.
2812
3434
  if (state.helpVisible) {
2813
3435
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
@@ -2825,11 +3447,122 @@ async function main() {
2825
3447
  return
2826
3448
  }
2827
3449
 
3450
+ // πŸ“– Smart Recommend overlay: full keyboard handling while overlay is open.
3451
+ if (state.recommendOpen) {
3452
+ if (key.ctrl && key.name === 'c') { exit(0); return }
3453
+
3454
+ if (state.recommendPhase === 'questionnaire') {
3455
+ const questions = [
3456
+ { options: Object.keys(TASK_TYPES), answerKey: 'taskType' },
3457
+ { options: Object.keys(PRIORITY_TYPES), answerKey: 'priority' },
3458
+ { options: Object.keys(CONTEXT_BUDGETS), answerKey: 'contextBudget' },
3459
+ ]
3460
+ const q = questions[state.recommendQuestion]
3461
+
3462
+ if (key.name === 'escape') {
3463
+ // πŸ“– Cancel recommend β€” close overlay
3464
+ state.recommendOpen = false
3465
+ state.recommendPhase = 'questionnaire'
3466
+ state.recommendQuestion = 0
3467
+ state.recommendCursor = 0
3468
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
3469
+ return
3470
+ }
3471
+ if (key.name === 'up') {
3472
+ state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : q.options.length - 1
3473
+ return
3474
+ }
3475
+ if (key.name === 'down') {
3476
+ state.recommendCursor = state.recommendCursor < q.options.length - 1 ? state.recommendCursor + 1 : 0
3477
+ return
3478
+ }
3479
+ if (key.name === 'return') {
3480
+ // πŸ“– Record answer and advance to next question or start analysis
3481
+ state.recommendAnswers[q.answerKey] = q.options[state.recommendCursor]
3482
+ if (state.recommendQuestion < questions.length - 1) {
3483
+ state.recommendQuestion++
3484
+ state.recommendCursor = 0
3485
+ } else {
3486
+ // πŸ“– All questions answered β€” start analysis phase
3487
+ startRecommendAnalysis()
3488
+ }
3489
+ return
3490
+ }
3491
+ return // πŸ“– Swallow all other keys
3492
+ }
3493
+
3494
+ if (state.recommendPhase === 'analyzing') {
3495
+ if (key.name === 'escape') {
3496
+ // πŸ“– Cancel analysis β€” stop timers, return to questionnaire
3497
+ stopRecommendAnalysis()
3498
+ state.recommendOpen = false
3499
+ state.recommendPhase = 'questionnaire'
3500
+ state.recommendQuestion = 0
3501
+ state.recommendCursor = 0
3502
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
3503
+ return
3504
+ }
3505
+ return // πŸ“– Swallow all keys during analysis (except Esc and Ctrl+C)
3506
+ }
3507
+
3508
+ if (state.recommendPhase === 'results') {
3509
+ if (key.name === 'escape') {
3510
+ // πŸ“– Close results β€” recommendations stay highlighted in main table
3511
+ state.recommendOpen = false
3512
+ return
3513
+ }
3514
+ if (key.name === 'q') {
3515
+ // πŸ“– Start a new search
3516
+ state.recommendPhase = 'questionnaire'
3517
+ state.recommendQuestion = 0
3518
+ state.recommendCursor = 0
3519
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
3520
+ state.recommendResults = []
3521
+ state.recommendScrollOffset = 0
3522
+ return
3523
+ }
3524
+ if (key.name === 'up') {
3525
+ const count = state.recommendResults.length
3526
+ if (count === 0) return
3527
+ state.recommendCursor = state.recommendCursor > 0 ? state.recommendCursor - 1 : count - 1
3528
+ return
3529
+ }
3530
+ if (key.name === 'down') {
3531
+ const count = state.recommendResults.length
3532
+ if (count === 0) return
3533
+ state.recommendCursor = state.recommendCursor < count - 1 ? state.recommendCursor + 1 : 0
3534
+ return
3535
+ }
3536
+ if (key.name === 'return') {
3537
+ // πŸ“– Select the highlighted recommendation β€” close overlay, jump cursor to it
3538
+ const rec = state.recommendResults[state.recommendCursor]
3539
+ if (rec) {
3540
+ const recKey = toFavoriteKey(rec.result.providerKey, rec.result.modelId)
3541
+ state.recommendOpen = false
3542
+ // πŸ“– Jump to the recommended model in the main table
3543
+ const idx = state.visibleSorted.findIndex(r => toFavoriteKey(r.providerKey, r.modelId) === recKey)
3544
+ if (idx >= 0) {
3545
+ state.cursor = idx
3546
+ adjustScrollOffset(state)
3547
+ }
3548
+ }
3549
+ return
3550
+ }
3551
+ return // πŸ“– Swallow all other keys
3552
+ }
3553
+
3554
+ return // πŸ“– Catch-all swallow
3555
+ }
3556
+
2828
3557
  // ─── Settings overlay keyboard handling ───────────────────────────────────
2829
3558
  if (state.settingsOpen) {
2830
3559
  const providerKeys = Object.keys(sources)
2831
3560
  const telemetryRowIdx = providerKeys.length
2832
3561
  const updateRowIdx = providerKeys.length + 1
3562
+ // πŸ“– Profile rows start after update row β€” one row per saved profile
3563
+ const savedProfiles = listProfiles(state.config)
3564
+ const profileStartIdx = updateRowIdx + 1
3565
+ const maxRowIdx = savedProfiles.length > 0 ? profileStartIdx + savedProfiles.length - 1 : updateRowIdx
2833
3566
 
2834
3567
  // πŸ“– Edit mode: capture typed characters for the API key
2835
3568
  if (state.settingsEditMode) {
@@ -2896,7 +3629,7 @@ async function main() {
2896
3629
  return
2897
3630
  }
2898
3631
 
2899
- if (key.name === 'down' && state.settingsCursor < updateRowIdx) {
3632
+ if (key.name === 'down' && state.settingsCursor < maxRowIdx) {
2900
3633
  state.settingsCursor++
2901
3634
  return
2902
3635
  }
@@ -2909,7 +3642,7 @@ async function main() {
2909
3642
 
2910
3643
  if (key.name === 'pagedown') {
2911
3644
  const pageStep = Math.max(1, (state.terminalRows || 1) - 2)
2912
- state.settingsCursor = Math.min(updateRowIdx, state.settingsCursor + pageStep)
3645
+ state.settingsCursor = Math.min(maxRowIdx, state.settingsCursor + pageStep)
2913
3646
  return
2914
3647
  }
2915
3648
 
@@ -2919,7 +3652,7 @@ async function main() {
2919
3652
  }
2920
3653
 
2921
3654
  if (key.name === 'end') {
2922
- state.settingsCursor = updateRowIdx
3655
+ state.settingsCursor = maxRowIdx
2923
3656
  return
2924
3657
  }
2925
3658
 
@@ -2940,6 +3673,33 @@ async function main() {
2940
3673
  return
2941
3674
  }
2942
3675
 
3676
+ // πŸ“– Profile row: Enter β†’ load the selected profile (apply its settings live)
3677
+ if (state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
3678
+ const profileIdx = state.settingsCursor - profileStartIdx
3679
+ const profileName = savedProfiles[profileIdx]
3680
+ if (profileName) {
3681
+ const settings = loadProfile(state.config, profileName)
3682
+ if (settings) {
3683
+ state.sortColumn = settings.sortColumn || 'avg'
3684
+ state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
3685
+ state.pingInterval = settings.pingInterval || PING_INTERVAL
3686
+ if (settings.tierFilter) {
3687
+ const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
3688
+ if (tierIdx >= 0) tierFilterMode = tierIdx
3689
+ } else {
3690
+ tierFilterMode = 0
3691
+ }
3692
+ state.activeProfile = profileName
3693
+ syncFavoriteFlags(state.results, state.config)
3694
+ applyTierFilter()
3695
+ const visible = state.results.filter(r => !r.hidden)
3696
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
3697
+ saveConfig(state.config)
3698
+ }
3699
+ }
3700
+ return
3701
+ }
3702
+
2943
3703
  // πŸ“– Enter edit mode for the selected provider's key
2944
3704
  const pk = providerKeys[state.settingsCursor]
2945
3705
  state.settingsEditBuffer = state.config.apiKeys?.[pk] ?? ''
@@ -2956,6 +3716,8 @@ async function main() {
2956
3716
  return
2957
3717
  }
2958
3718
  if (state.settingsCursor === updateRowIdx) return
3719
+ // πŸ“– Profile rows don't respond to Space
3720
+ if (state.settingsCursor >= profileStartIdx) return
2959
3721
 
2960
3722
  // πŸ“– Toggle enabled/disabled for selected provider
2961
3723
  const pk = providerKeys[state.settingsCursor]
@@ -2968,6 +3730,8 @@ async function main() {
2968
3730
 
2969
3731
  if (key.name === 't') {
2970
3732
  if (state.settingsCursor === telemetryRowIdx || state.settingsCursor === updateRowIdx) return
3733
+ // πŸ“– Profile rows don't respond to T (test key)
3734
+ if (state.settingsCursor >= profileStartIdx) return
2971
3735
 
2972
3736
  // πŸ“– Test the selected provider's key (fires a real ping)
2973
3737
  const pk = providerKeys[state.settingsCursor]
@@ -2980,12 +3744,34 @@ async function main() {
2980
3744
  return
2981
3745
  }
2982
3746
 
3747
+ // πŸ“– Backspace on a profile row β†’ delete that profile
3748
+ if (key.name === 'backspace' && state.settingsCursor >= profileStartIdx && savedProfiles.length > 0) {
3749
+ const profileIdx = state.settingsCursor - profileStartIdx
3750
+ const profileName = savedProfiles[profileIdx]
3751
+ if (profileName) {
3752
+ deleteProfile(state.config, profileName)
3753
+ // πŸ“– If the deleted profile was active, clear active state
3754
+ if (state.activeProfile === profileName) {
3755
+ setActiveProfile(state.config, null)
3756
+ state.activeProfile = null
3757
+ }
3758
+ saveConfig(state.config)
3759
+ // πŸ“– Re-clamp cursor after deletion (profile list just got shorter)
3760
+ const newProfiles = listProfiles(state.config)
3761
+ const newMaxRowIdx = newProfiles.length > 0 ? profileStartIdx + newProfiles.length - 1 : updateRowIdx
3762
+ if (state.settingsCursor > newMaxRowIdx) {
3763
+ state.settingsCursor = Math.max(0, newMaxRowIdx)
3764
+ }
3765
+ }
3766
+ return
3767
+ }
3768
+
2983
3769
  if (key.ctrl && key.name === 'c') { exit(0); return }
2984
3770
  return // πŸ“– Swallow all other keys while settings is open
2985
3771
  }
2986
3772
 
2987
3773
  // πŸ“– P key: open settings screen
2988
- if (key.name === 'p') {
3774
+ if (key.name === 'p' && !key.shift) {
2989
3775
  state.settingsOpen = true
2990
3776
  state.settingsCursor = 0
2991
3777
  state.settingsEditMode = false
@@ -2994,15 +3780,86 @@ async function main() {
2994
3780
  return
2995
3781
  }
2996
3782
 
2997
- // πŸ“– 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, U=uptime
3783
+ // πŸ“– Q key: open Smart Recommend overlay
3784
+ if (key.name === 'q') {
3785
+ state.recommendOpen = true
3786
+ state.recommendPhase = 'questionnaire'
3787
+ state.recommendQuestion = 0
3788
+ state.recommendCursor = 0
3789
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
3790
+ state.recommendResults = []
3791
+ state.recommendScrollOffset = 0
3792
+ return
3793
+ }
3794
+
3795
+ // πŸ“– Shift+P: cycle through profiles (or show profile picker)
3796
+ if (key.name === 'p' && key.shift) {
3797
+ const profiles = listProfiles(state.config)
3798
+ if (profiles.length === 0) {
3799
+ // πŸ“– No profiles saved β€” save current config as 'default' profile
3800
+ saveAsProfile(state.config, 'default', {
3801
+ tierFilter: TIER_CYCLE[tierFilterMode],
3802
+ sortColumn: state.sortColumn,
3803
+ sortAsc: state.sortDirection === 'asc',
3804
+ pingInterval: state.pingInterval,
3805
+ })
3806
+ setActiveProfile(state.config, 'default')
3807
+ state.activeProfile = 'default'
3808
+ saveConfig(state.config)
3809
+ } else {
3810
+ // πŸ“– Cycle to next profile (or back to null = raw config)
3811
+ const currentIdx = state.activeProfile ? profiles.indexOf(state.activeProfile) : -1
3812
+ const nextIdx = (currentIdx + 1) % (profiles.length + 1) // +1 for "no profile"
3813
+ if (nextIdx === profiles.length) {
3814
+ // πŸ“– Back to raw config (no profile)
3815
+ setActiveProfile(state.config, null)
3816
+ state.activeProfile = null
3817
+ saveConfig(state.config)
3818
+ } else {
3819
+ const nextProfile = profiles[nextIdx]
3820
+ const settings = loadProfile(state.config, nextProfile)
3821
+ if (settings) {
3822
+ // πŸ“– Apply profile's TUI settings to live state
3823
+ state.sortColumn = settings.sortColumn || 'avg'
3824
+ state.sortDirection = settings.sortAsc ? 'asc' : 'desc'
3825
+ state.pingInterval = settings.pingInterval || PING_INTERVAL
3826
+ if (settings.tierFilter) {
3827
+ const tierIdx = TIER_CYCLE.indexOf(settings.tierFilter)
3828
+ if (tierIdx >= 0) tierFilterMode = tierIdx
3829
+ } else {
3830
+ tierFilterMode = 0
3831
+ }
3832
+ state.activeProfile = nextProfile
3833
+ // πŸ“– Rebuild favorites from profile data
3834
+ syncFavoriteFlags(state.results, state.config)
3835
+ applyTierFilter()
3836
+ const visible = state.results.filter(r => !r.hidden)
3837
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
3838
+ state.cursor = 0
3839
+ state.scrollOffset = 0
3840
+ saveConfig(state.config)
3841
+ }
3842
+ }
3843
+ }
3844
+ return
3845
+ }
3846
+
3847
+ // πŸ“– Shift+S: enter profile save mode β€” inline text prompt for typing a profile name
3848
+ if (key.name === 's' && key.shift) {
3849
+ state.profileSaveMode = true
3850
+ state.profileSaveBuffer = ''
3851
+ return
3852
+ }
3853
+
3854
+ // πŸ“– 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
2998
3855
  // πŸ“– T is reserved for tier filter cycling β€” tier sort moved to Y
2999
3856
  // πŸ“– N is now reserved for origin filter cycling
3000
3857
  const sortKeys = {
3001
3858
  'r': 'rank', 'y': 'tier', 'o': 'origin', 'm': 'model',
3002
- 'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'u': 'uptime'
3859
+ 'l': 'ping', 'a': 'avg', 's': 'swe', 'c': 'ctx', 'h': 'condition', 'v': 'verdict', 'b': 'stability', 'u': 'uptime'
3003
3860
  }
3004
3861
 
3005
- if (sortKeys[key.name] && !key.ctrl) {
3862
+ if (sortKeys[key.name] && !key.ctrl && !key.shift) {
3006
3863
  const col = sortKeys[key.name]
3007
3864
  // πŸ“– Toggle direction if same column, otherwise reset to asc
3008
3865
  if (state.sortColumn === col) {
@@ -3178,19 +4035,21 @@ async function main() {
3178
4035
 
3179
4036
  process.stdin.on('keypress', onKeyPress)
3180
4037
 
3181
- // πŸ“– Animation loop: render settings overlay OR main table based on state
4038
+ // πŸ“– Animation loop: render settings overlay, recommend overlay, help overlay, OR main table
3182
4039
  const ticker = setInterval(() => {
3183
4040
  state.frame++
3184
4041
  // πŸ“– Cache visible+sorted models each frame so Enter handler always matches the display
3185
- if (!state.settingsOpen) {
4042
+ if (!state.settingsOpen && !state.recommendOpen) {
3186
4043
  const visible = state.results.filter(r => !r.hidden)
3187
4044
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
3188
4045
  }
3189
4046
  const content = state.settingsOpen
3190
4047
  ? renderSettings()
3191
- : state.helpVisible
3192
- ? renderHelp()
3193
- : 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)
4048
+ : state.recommendOpen
4049
+ ? renderRecommend()
4050
+ : state.helpVisible
4051
+ ? renderHelp()
4052
+ : 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)
3194
4053
  process.stdout.write(ALT_HOME + content)
3195
4054
  }, Math.round(1000 / FPS))
3196
4055
 
@@ -3198,7 +4057,19 @@ async function main() {
3198
4057
  const initialVisible = state.results.filter(r => !r.hidden)
3199
4058
  state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
3200
4059
 
3201
- 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))
4060
+ 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))
4061
+
4062
+ // πŸ“– If --recommend was passed, auto-open the Smart Recommend overlay on start
4063
+ if (cliArgs.recommendMode) {
4064
+ state.recommendOpen = true
4065
+ state.recommendPhase = 'questionnaire'
4066
+ state.recommendCursor = 0
4067
+ state.recommendQuestion = 0
4068
+ state.recommendAnswers = { taskType: null, priority: null, contextBudget: null }
4069
+ state.recommendProgress = 0
4070
+ state.recommendResults = []
4071
+ state.recommendScrollOffset = 0
4072
+ }
3202
4073
 
3203
4074
  // ── Continuous ping loop β€” ping all models every N seconds forever ──────────
3204
4075