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.
- package/README.md +183 -30
- package/bin/free-coding-models.js +990 -119
- package/lib/config.js +164 -3
- package/lib/utils.js +293 -30
- package/package.json +1 -1
- package/sources.js +17 -0
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* π― Key features:
|
|
13
13
|
* - Parallel pings across all models with animated real-time updates (multi-provider)
|
|
14
|
-
* - Continuous monitoring with
|
|
14
|
+
* - Continuous monitoring with 60-second ping intervals (never stops)
|
|
15
15
|
* - Rolling averages calculated from ALL successful pings since start
|
|
16
16
|
* - Best-per-tier highlighting with medals (π₯π₯π₯)
|
|
17
17
|
* - Interactive navigation with arrow keys directly in the table
|
|
@@ -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:
|
|
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 =
|
|
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 =
|
|
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
|
-
// π
|
|
834
|
-
// π
|
|
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
|
-
|
|
840
|
-
|
|
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
|
|
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)) :
|
|
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
|
-
|
|
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
|
|
1003
|
-
|
|
1004
|
-
const
|
|
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
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
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('
|
|
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 "
|
|
1036
|
-
pingCell = chalk.dim('
|
|
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('
|
|
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
|
|
1172
|
+
const status = statusColor(padEndDisplay(statusText, W_STATUS))
|
|
1084
1173
|
|
|
1085
|
-
// π Verdict column -
|
|
1086
|
-
const
|
|
1174
|
+
// π Verdict column - use getVerdict() for stability-aware verdicts, then render with emoji
|
|
1175
|
+
const verdict = getVerdict(r)
|
|
1087
1176
|
let verdictText, verdictColor
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
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
|
-
|
|
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.
|
|
1245
|
+
uptimeCell = chalk.greenBright(uptimeStr.padEnd(W_UPTIME))
|
|
1124
1246
|
} else if (uptimePercent >= 70) {
|
|
1125
|
-
uptimeCell = chalk.yellow(uptimeStr.
|
|
1247
|
+
uptimeCell = chalk.yellow(uptimeStr.padEnd(W_UPTIME))
|
|
1126
1248
|
} else if (uptimePercent >= 50) {
|
|
1127
|
-
uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.
|
|
1249
|
+
uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.padEnd(W_UPTIME)) // orange
|
|
1128
1250
|
} else {
|
|
1129
|
-
uptimeCell = chalk.red(uptimeStr.
|
|
1251
|
+
uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
|
|
1130
1252
|
}
|
|
1131
1253
|
|
|
1132
|
-
// π
|
|
1133
|
-
const
|
|
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
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1429
|
-
// π
|
|
1430
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
// π
|
|
1669
|
-
// π
|
|
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
|
|
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 <
|
|
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(
|
|
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 =
|
|
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
|
-
// π
|
|
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
|
|
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.
|
|
3192
|
-
?
|
|
3193
|
-
:
|
|
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
|
|