free-coding-models 0.1.82 → 0.1.84
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 +53 -51
- package/bin/free-coding-models.js +429 -4276
- package/package.json +2 -2
- package/sources.js +3 -2
- package/src/account-manager.js +600 -0
- package/src/analysis.js +197 -0
- package/{lib → src}/config.js +122 -0
- package/src/constants.js +116 -0
- package/src/error-classifier.js +154 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/log-reader.js +174 -0
- package/src/model-merger.js +78 -0
- package/src/openclaw.js +131 -0
- package/src/opencode-sync.js +159 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/provider-quota-fetchers.js +319 -0
- package/src/proxy-server.js +543 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/request-transformer.js +180 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/src/token-stats.js +310 -0
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/src/usage-reader.js +245 -0
- package/{lib → src}/utils.js +55 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file render-table.js
|
|
3
|
+
* @description Master table renderer for the main TUI list.
|
|
4
|
+
*
|
|
5
|
+
* @details
|
|
6
|
+
* This module contains the full renderTable implementation used by the CLI.
|
|
7
|
+
* It renders the header, model rows, status indicators, and footer hints
|
|
8
|
+
* with consistent alignment, colorization, and viewport clipping.
|
|
9
|
+
*
|
|
10
|
+
* 🎯 Key features:
|
|
11
|
+
* - Full table layout with tier, latency, stability, uptime, token totals, and usage columns
|
|
12
|
+
* - Hotkey-aware header lettering so highlighted letters always match live sort/filter keys
|
|
13
|
+
* - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
|
|
14
|
+
* - Viewport clipping with above/below indicators
|
|
15
|
+
* - Smart badges (mode, tier filter, origin filter, profile)
|
|
16
|
+
* - Proxy status line integrated in footer
|
|
17
|
+
*
|
|
18
|
+
* → Functions:
|
|
19
|
+
* - `setActiveProxy` — Provide the active proxy instance for footer status rendering
|
|
20
|
+
* - `renderTable` — Render the full TUI table as a string (no side effects)
|
|
21
|
+
*
|
|
22
|
+
* 📦 Dependencies:
|
|
23
|
+
* - ../sources.js: sources provider metadata
|
|
24
|
+
* - ../src/constants.js: PING_INTERVAL, FRAMES
|
|
25
|
+
* - ../src/tier-colors.js: TIER_COLOR
|
|
26
|
+
* - ../src/utils.js: getAvg, getVerdict, getUptime, getStabilityScore
|
|
27
|
+
* - ../src/ping.js: usagePlaceholderForProvider
|
|
28
|
+
* - ../src/render-helpers.js: calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLine, padEndDisplay
|
|
29
|
+
*
|
|
30
|
+
* @see bin/free-coding-models.js — main entry point that calls renderTable
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import chalk from 'chalk'
|
|
34
|
+
import { createRequire } from 'module'
|
|
35
|
+
import { sources } from '../sources.js'
|
|
36
|
+
import { PING_INTERVAL, FRAMES } from './constants.js'
|
|
37
|
+
import { TIER_COLOR } from './tier-colors.js'
|
|
38
|
+
import { getAvg, getVerdict, getUptime, getStabilityScore } from './utils.js'
|
|
39
|
+
import { usagePlaceholderForProvider } from './ping.js'
|
|
40
|
+
import { formatTokenTotalCompact } from './token-usage-reader.js'
|
|
41
|
+
import { calculateViewport, sortResultsWithPinnedFavorites, renderProxyStatusLine, padEndDisplay } from './render-helpers.js'
|
|
42
|
+
|
|
43
|
+
const require = createRequire(import.meta.url)
|
|
44
|
+
const { version: LOCAL_VERSION } = require('../package.json')
|
|
45
|
+
|
|
46
|
+
// 📖 Provider column palette: keep all Origins in the same visual family
|
|
47
|
+
// 📖 (blue/cyan tones) while making each provider easy to distinguish at a glance.
|
|
48
|
+
const PROVIDER_COLOR = {
|
|
49
|
+
nvidia: [120, 205, 255],
|
|
50
|
+
groq: [95, 185, 255],
|
|
51
|
+
cerebras: [70, 165, 255],
|
|
52
|
+
sambanova: [45, 145, 245],
|
|
53
|
+
openrouter: [135, 220, 255],
|
|
54
|
+
huggingface: [110, 190, 235],
|
|
55
|
+
replicate: [85, 175, 230],
|
|
56
|
+
deepinfra: [60, 160, 225],
|
|
57
|
+
fireworks: [125, 215, 245],
|
|
58
|
+
codestral: [100, 180, 240],
|
|
59
|
+
hyperbolic: [75, 170, 240],
|
|
60
|
+
scaleway: [55, 150, 235],
|
|
61
|
+
googleai: [130, 210, 255],
|
|
62
|
+
siliconflow: [90, 195, 245],
|
|
63
|
+
together: [65, 155, 245],
|
|
64
|
+
cloudflare: [115, 200, 240],
|
|
65
|
+
perplexity: [140, 225, 255],
|
|
66
|
+
qwen: [80, 185, 235],
|
|
67
|
+
zai: [50, 140, 225],
|
|
68
|
+
iflow: [145, 230, 255],
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 📖 Active proxy reference for footer status line (set by bin/free-coding-models.js).
|
|
72
|
+
let activeProxyRef = null
|
|
73
|
+
|
|
74
|
+
// 📖 setActiveProxy: Store active proxy instance for renderTable footer line.
|
|
75
|
+
export function setActiveProxy(proxyInstance) {
|
|
76
|
+
activeProxyRef = proxyInstance
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
|
|
80
|
+
export 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 = '', proxyStartupStatus = null) {
|
|
81
|
+
// 📖 Filter out hidden models for display
|
|
82
|
+
const visibleResults = results.filter(r => !r.hidden)
|
|
83
|
+
|
|
84
|
+
const up = visibleResults.filter(r => r.status === 'up').length
|
|
85
|
+
const down = visibleResults.filter(r => r.status === 'down').length
|
|
86
|
+
const timeout = visibleResults.filter(r => r.status === 'timeout').length
|
|
87
|
+
const pending = visibleResults.filter(r => r.status === 'pending').length
|
|
88
|
+
|
|
89
|
+
// 📖 Calculate seconds until next ping
|
|
90
|
+
const timeSinceLastPing = Date.now() - lastPingTime
|
|
91
|
+
const timeUntilNextPing = Math.max(0, pingInterval - timeSinceLastPing)
|
|
92
|
+
const secondsUntilNext = Math.ceil(timeUntilNextPing / 1000)
|
|
93
|
+
|
|
94
|
+
const phase = pending > 0
|
|
95
|
+
? chalk.dim(`discovering — ${pending} remaining…`)
|
|
96
|
+
: pendingPings > 0
|
|
97
|
+
? chalk.dim(`pinging — ${pendingPings} in flight…`)
|
|
98
|
+
: chalk.dim(`next ping ${secondsUntilNext}s`)
|
|
99
|
+
|
|
100
|
+
// 📖 Mode badge shown in header so user knows what Enter will do
|
|
101
|
+
// 📖 Now includes key hint for mode toggle
|
|
102
|
+
let modeBadge
|
|
103
|
+
if (mode === 'openclaw') {
|
|
104
|
+
modeBadge = chalk.bold.rgb(255, 100, 50)(' [🦞 OpenClaw]')
|
|
105
|
+
} else if (mode === 'opencode-desktop') {
|
|
106
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [🖥 Desktop]')
|
|
107
|
+
} else {
|
|
108
|
+
modeBadge = chalk.bold.rgb(0, 200, 255)(' [💻 CLI]')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 📖 Add mode toggle hint
|
|
112
|
+
const modeHint = chalk.dim.yellow(' (Z to toggle)')
|
|
113
|
+
|
|
114
|
+
// 📖 Tier filter badge shown when filtering is active (shows exact tier name)
|
|
115
|
+
const TIER_CYCLE_NAMES = [null, 'S+', 'S', 'A+', 'A', 'A-', 'B+', 'B', 'C']
|
|
116
|
+
let tierBadge = ''
|
|
117
|
+
if (tierFilterMode > 0) {
|
|
118
|
+
tierBadge = chalk.bold.rgb(255, 200, 0)(` [${TIER_CYCLE_NAMES[tierFilterMode]}]`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const normalizeOriginLabel = (name, key) => {
|
|
122
|
+
if (key === 'qwen') return 'Alibaba'
|
|
123
|
+
return name
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 📖 Origin filter badge — shown when filtering by provider is active
|
|
127
|
+
let originBadge = ''
|
|
128
|
+
if (originFilterMode > 0) {
|
|
129
|
+
const originKeys = [null, ...Object.keys(sources)]
|
|
130
|
+
const activeOriginKey = originKeys[originFilterMode]
|
|
131
|
+
const activeOriginName = activeOriginKey ? sources[activeOriginKey]?.name ?? activeOriginKey : null
|
|
132
|
+
if (activeOriginName) {
|
|
133
|
+
originBadge = chalk.bold.rgb(100, 200, 255)(` [${normalizeOriginLabel(activeOriginName, activeOriginKey)}]`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 📖 Profile badge — shown when a named profile is active (Shift+P to cycle, Shift+S to save)
|
|
138
|
+
let profileBadge = ''
|
|
139
|
+
if (activeProfile) {
|
|
140
|
+
profileBadge = chalk.bold.rgb(200, 150, 255)(` [📋 ${activeProfile}]`)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 📖 Column widths (generous spacing with margins)
|
|
144
|
+
const W_RANK = 6
|
|
145
|
+
const W_TIER = 6
|
|
146
|
+
const W_CTX = 6
|
|
147
|
+
const W_SOURCE = 14
|
|
148
|
+
const W_MODEL = 26
|
|
149
|
+
const W_SWE = 9
|
|
150
|
+
const W_PING = 14
|
|
151
|
+
const W_AVG = 11
|
|
152
|
+
const W_STATUS = 18
|
|
153
|
+
const W_VERDICT = 14
|
|
154
|
+
const W_STAB = 11
|
|
155
|
+
const W_UPTIME = 6
|
|
156
|
+
const W_TOKENS = 7
|
|
157
|
+
const W_USAGE = 7
|
|
158
|
+
|
|
159
|
+
// 📖 Sort models using the shared helper
|
|
160
|
+
const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
|
|
161
|
+
|
|
162
|
+
const lines = [
|
|
163
|
+
` ${chalk.greenBright.bold('✅ FCM')}${modeBadge}${modeHint}${tierBadge}${originBadge}${profileBadge} ` +
|
|
164
|
+
chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
|
|
165
|
+
chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
|
|
166
|
+
chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
|
|
167
|
+
phase,
|
|
168
|
+
'',
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
// 📖 Header row with sorting indicators
|
|
172
|
+
// 📖 NOTE: padEnd on chalk strings counts ANSI codes, breaking alignment
|
|
173
|
+
// 📖 Solution: build plain text first, then colorize
|
|
174
|
+
const dir = sortDirection === 'asc' ? '↑' : '↓'
|
|
175
|
+
|
|
176
|
+
const rankH = 'Rank'
|
|
177
|
+
const tierH = 'Tier'
|
|
178
|
+
const originH = 'Provider'
|
|
179
|
+
const modelH = 'Model'
|
|
180
|
+
const sweH = sortColumn === 'swe' ? dir + ' SWE%' : 'SWE%'
|
|
181
|
+
const ctxH = sortColumn === 'ctx' ? dir + ' CTX' : 'CTX'
|
|
182
|
+
const pingH = sortColumn === 'ping' ? dir + ' Latest Ping' : 'Latest Ping'
|
|
183
|
+
const avgH = sortColumn === 'avg' ? dir + ' Avg Ping' : 'Avg Ping'
|
|
184
|
+
const healthH = sortColumn === 'condition' ? dir + ' Health' : 'Health'
|
|
185
|
+
const verdictH = sortColumn === 'verdict' ? dir + ' Verdict' : 'Verdict'
|
|
186
|
+
const stabH = sortColumn === 'stability' ? dir + ' Stability' : 'Stability'
|
|
187
|
+
const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
|
|
188
|
+
const tokensH = 'Used'
|
|
189
|
+
const usageH = sortColumn === 'usage' ? dir + ' Usage' : 'Usage'
|
|
190
|
+
|
|
191
|
+
// 📖 Helper to colorize first letter for keyboard shortcuts
|
|
192
|
+
// 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
|
|
193
|
+
const colorFirst = (text, width, colorFn = chalk.yellow) => {
|
|
194
|
+
const first = text[0]
|
|
195
|
+
const rest = text.slice(1)
|
|
196
|
+
const plainText = first + rest
|
|
197
|
+
const padding = ' '.repeat(Math.max(0, width - plainText.length))
|
|
198
|
+
return colorFn(first) + chalk.dim(rest + padding)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 📖 Now colorize after padding is calculated on plain text
|
|
202
|
+
const rankH_c = colorFirst(rankH, W_RANK)
|
|
203
|
+
const tierH_c = colorFirst('Tier', W_TIER)
|
|
204
|
+
const originLabel = 'Provider'
|
|
205
|
+
const originH_c = sortColumn === 'origin'
|
|
206
|
+
? chalk.bold.cyan(originLabel.padEnd(W_SOURCE))
|
|
207
|
+
: (originFilterMode > 0 ? chalk.bold.rgb(100, 200, 255)(originLabel.padEnd(W_SOURCE)) : (() => {
|
|
208
|
+
// 📖 Provider keeps O for sorting and D for provider-filter cycling.
|
|
209
|
+
const plain = 'PrOviDer'
|
|
210
|
+
const padding = ' '.repeat(Math.max(0, W_SOURCE - plain.length))
|
|
211
|
+
return chalk.dim('Pr') + chalk.yellow.bold('O') + chalk.dim('vi') + chalk.yellow.bold('D') + chalk.dim('er' + padding)
|
|
212
|
+
})())
|
|
213
|
+
const modelH_c = colorFirst(modelH, W_MODEL)
|
|
214
|
+
const sweH_c = sortColumn === 'swe' ? chalk.bold.cyan(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
|
|
215
|
+
const ctxH_c = sortColumn === 'ctx' ? chalk.bold.cyan(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
|
|
216
|
+
const pingH_c = sortColumn === 'ping' ? chalk.bold.cyan(pingH.padEnd(W_PING)) : colorFirst('Latest Ping', W_PING)
|
|
217
|
+
const avgH_c = sortColumn === 'avg' ? chalk.bold.cyan(avgH.padEnd(W_AVG)) : colorFirst('Avg Ping', W_AVG)
|
|
218
|
+
const healthH_c = sortColumn === 'condition' ? chalk.bold.cyan(healthH.padEnd(W_STATUS)) : colorFirst('Health', W_STATUS)
|
|
219
|
+
const verdictH_c = sortColumn === 'verdict' ? chalk.bold.cyan(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
|
|
220
|
+
// 📖 Custom colorization for Stability: highlight 'B' (the sort key) since 'S' is taken by SWE
|
|
221
|
+
const stabH_c = sortColumn === 'stability' ? chalk.bold.cyan(stabH.padEnd(W_STAB)) : (() => {
|
|
222
|
+
const plain = 'Stability'
|
|
223
|
+
const padding = ' '.repeat(Math.max(0, W_STAB - plain.length))
|
|
224
|
+
return chalk.dim('Sta') + chalk.yellow.bold('B') + chalk.dim('ility' + padding)
|
|
225
|
+
})()
|
|
226
|
+
// 📖 Up% sorts on U, so keep the highlighted shortcut in the shared yellow sort-key color.
|
|
227
|
+
const uptimeH_c = sortColumn === 'uptime' ? chalk.bold.cyan(uptimeH.padEnd(W_UPTIME)) : (() => {
|
|
228
|
+
const plain = 'Up%'
|
|
229
|
+
const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
|
|
230
|
+
return chalk.yellow.bold('U') + chalk.dim('p%' + padding)
|
|
231
|
+
})()
|
|
232
|
+
const tokensH_c = chalk.dim(tokensH.padEnd(W_TOKENS))
|
|
233
|
+
// 📖 Usage sorts on plain G, so the highlighted letter must stay in the visible header.
|
|
234
|
+
const usageH_c = sortColumn === 'usage' ? chalk.bold.cyan(usageH.padEnd(W_USAGE)) : (() => {
|
|
235
|
+
const plain = 'UsaGe'
|
|
236
|
+
const padding = ' '.repeat(Math.max(0, W_USAGE - plain.length))
|
|
237
|
+
return chalk.dim('Usa') + chalk.yellow.bold('G') + chalk.dim('e' + padding)
|
|
238
|
+
})()
|
|
239
|
+
|
|
240
|
+
// 📖 Header with proper spacing (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used, Usage)
|
|
241
|
+
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 + ' ' + tokensH_c + ' ' + usageH_c)
|
|
242
|
+
|
|
243
|
+
// 📖 Separator line
|
|
244
|
+
lines.push(
|
|
245
|
+
' ' +
|
|
246
|
+
chalk.dim('─'.repeat(W_RANK)) + ' ' +
|
|
247
|
+
chalk.dim('─'.repeat(W_TIER)) + ' ' +
|
|
248
|
+
chalk.dim('─'.repeat(W_SWE)) + ' ' +
|
|
249
|
+
chalk.dim('─'.repeat(W_CTX)) + ' ' +
|
|
250
|
+
'─'.repeat(W_MODEL) + ' ' +
|
|
251
|
+
'─'.repeat(W_SOURCE) + ' ' +
|
|
252
|
+
chalk.dim('─'.repeat(W_PING)) + ' ' +
|
|
253
|
+
chalk.dim('─'.repeat(W_AVG)) + ' ' +
|
|
254
|
+
chalk.dim('─'.repeat(W_STATUS)) + ' ' +
|
|
255
|
+
chalk.dim('─'.repeat(W_VERDICT)) + ' ' +
|
|
256
|
+
chalk.dim('─'.repeat(W_STAB)) + ' ' +
|
|
257
|
+
chalk.dim('─'.repeat(W_UPTIME)) + ' ' +
|
|
258
|
+
chalk.dim('─'.repeat(W_TOKENS)) + ' ' +
|
|
259
|
+
chalk.dim('─'.repeat(W_USAGE))
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
// 📖 Viewport clipping: only render models that fit on screen
|
|
263
|
+
const vp = calculateViewport(terminalRows, scrollOffset, sorted.length)
|
|
264
|
+
|
|
265
|
+
if (vp.hasAbove) {
|
|
266
|
+
lines.push(chalk.dim(` ... ${vp.startIdx} more above ...`))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
for (let i = vp.startIdx; i < vp.endIdx; i++) {
|
|
270
|
+
const r = sorted[i]
|
|
271
|
+
const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
|
|
272
|
+
|
|
273
|
+
const isCursor = cursor !== null && i === cursor
|
|
274
|
+
|
|
275
|
+
// 📖 Left-aligned columns - pad plain text first, then colorize
|
|
276
|
+
const num = chalk.dim(String(r.idx).padEnd(W_RANK))
|
|
277
|
+
const tier = tierFn(r.tier.padEnd(W_TIER))
|
|
278
|
+
// 📖 Keep terminal view provider-specific so each row is monitorable per provider
|
|
279
|
+
const providerNameRaw = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
|
|
280
|
+
const providerName = normalizeOriginLabel(providerNameRaw, r.providerKey)
|
|
281
|
+
const providerRgb = PROVIDER_COLOR[r.providerKey] ?? [105, 190, 245]
|
|
282
|
+
const source = chalk.rgb(...providerRgb)(providerName.padEnd(W_SOURCE))
|
|
283
|
+
// 📖 Favorites: always reserve 2 display columns at the start of Model column.
|
|
284
|
+
// 📖 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
|
|
285
|
+
const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
|
|
286
|
+
const prefixDisplayWidth = 2
|
|
287
|
+
const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
|
|
288
|
+
const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
|
|
289
|
+
const sweScore = r.sweScore ?? '—'
|
|
290
|
+
// 📖 SWE% colorized on the same gradient as Tier:
|
|
291
|
+
// ≥70% bright neon green (S+), ≥60% green (S), ≥50% yellow-green (A+),
|
|
292
|
+
// ≥40% yellow (A), ≥35% amber (A-), ≥30% orange-red (B+),
|
|
293
|
+
// ≥20% red (B), <20% dark red (C), '—' dim
|
|
294
|
+
let sweCell
|
|
295
|
+
if (sweScore === '—') {
|
|
296
|
+
sweCell = chalk.dim(sweScore.padEnd(W_SWE))
|
|
297
|
+
} else {
|
|
298
|
+
const sweVal = parseFloat(sweScore)
|
|
299
|
+
const swePadded = sweScore.padEnd(W_SWE)
|
|
300
|
+
if (sweVal >= 70) sweCell = chalk.bold.rgb(0, 255, 80)(swePadded)
|
|
301
|
+
else if (sweVal >= 60) sweCell = chalk.bold.rgb(80, 220, 0)(swePadded)
|
|
302
|
+
else if (sweVal >= 50) sweCell = chalk.bold.rgb(170, 210, 0)(swePadded)
|
|
303
|
+
else if (sweVal >= 40) sweCell = chalk.rgb(240, 190, 0)(swePadded)
|
|
304
|
+
else if (sweVal >= 35) sweCell = chalk.rgb(255, 130, 0)(swePadded)
|
|
305
|
+
else if (sweVal >= 30) sweCell = chalk.rgb(255, 70, 0)(swePadded)
|
|
306
|
+
else if (sweVal >= 20) sweCell = chalk.rgb(210, 20, 0)(swePadded)
|
|
307
|
+
else sweCell = chalk.rgb(140, 0, 0)(swePadded)
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// 📖 Context window column - colorized by size (larger = better)
|
|
311
|
+
const ctxRaw = r.ctx ?? '—'
|
|
312
|
+
const ctxCell = ctxRaw !== '—' && (ctxRaw.includes('128k') || ctxRaw.includes('200k') || ctxRaw.includes('1m'))
|
|
313
|
+
? chalk.greenBright(ctxRaw.padEnd(W_CTX))
|
|
314
|
+
: ctxRaw !== '—' && (ctxRaw.includes('32k') || ctxRaw.includes('64k'))
|
|
315
|
+
? chalk.cyan(ctxRaw.padEnd(W_CTX))
|
|
316
|
+
: chalk.dim(ctxRaw.padEnd(W_CTX))
|
|
317
|
+
|
|
318
|
+
// 📖 Latest ping - pings are objects: { ms, code }
|
|
319
|
+
// 📖 Show response time for 200 (success) and 401 (no-auth but server is reachable)
|
|
320
|
+
const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
|
|
321
|
+
let pingCell
|
|
322
|
+
if (!latestPing) {
|
|
323
|
+
pingCell = chalk.dim('———'.padEnd(W_PING))
|
|
324
|
+
} else if (latestPing.code === '200') {
|
|
325
|
+
// 📖 Success - show response time
|
|
326
|
+
const str = String(latestPing.ms).padEnd(W_PING)
|
|
327
|
+
pingCell = latestPing.ms < 500 ? chalk.greenBright(str) : latestPing.ms < 1500 ? chalk.yellow(str) : chalk.red(str)
|
|
328
|
+
} else if (latestPing.code === '401') {
|
|
329
|
+
// 📖 401 = no API key but server IS reachable — still show latency in dim
|
|
330
|
+
pingCell = chalk.dim(String(latestPing.ms).padEnd(W_PING))
|
|
331
|
+
} else {
|
|
332
|
+
// 📖 Error or timeout - show "———" (error code is already in Status column)
|
|
333
|
+
pingCell = chalk.dim('———'.padEnd(W_PING))
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 📖 Avg ping (just number, no "ms")
|
|
337
|
+
const avg = getAvg(r)
|
|
338
|
+
let avgCell
|
|
339
|
+
if (avg !== Infinity) {
|
|
340
|
+
const str = String(avg).padEnd(W_AVG)
|
|
341
|
+
avgCell = avg < 500 ? chalk.greenBright(str) : avg < 1500 ? chalk.yellow(str) : chalk.red(str)
|
|
342
|
+
} else {
|
|
343
|
+
avgCell = chalk.dim('———'.padEnd(W_AVG))
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 📖 Status column - build plain text with emoji, pad, then colorize
|
|
347
|
+
// 📖 Different emojis for different error codes
|
|
348
|
+
let statusText, statusColor
|
|
349
|
+
if (r.status === 'noauth') {
|
|
350
|
+
// 📖 Server responded but needs an API key — shown dimly since it IS reachable
|
|
351
|
+
statusText = `🔑 NO KEY`
|
|
352
|
+
statusColor = (s) => chalk.dim(s)
|
|
353
|
+
} else if (r.status === 'pending') {
|
|
354
|
+
statusText = `${FRAMES[frame % FRAMES.length]} wait`
|
|
355
|
+
statusColor = (s) => chalk.dim.yellow(s)
|
|
356
|
+
} else if (r.status === 'up') {
|
|
357
|
+
statusText = `✅ UP`
|
|
358
|
+
statusColor = (s) => s
|
|
359
|
+
} else if (r.status === 'timeout') {
|
|
360
|
+
statusText = `⏳ TIMEOUT`
|
|
361
|
+
statusColor = (s) => chalk.yellow(s)
|
|
362
|
+
} else if (r.status === 'down') {
|
|
363
|
+
const code = r.httpCode ?? 'ERR'
|
|
364
|
+
// 📖 Different emojis for different error codes
|
|
365
|
+
const errorEmojis = {
|
|
366
|
+
'429': '🔥', // Rate limited / overloaded
|
|
367
|
+
'404': '🚫', // Not found
|
|
368
|
+
'500': '💥', // Internal server error
|
|
369
|
+
'502': '🔌', // Bad gateway
|
|
370
|
+
'503': '🔒', // Service unavailable
|
|
371
|
+
'504': '⏰', // Gateway timeout
|
|
372
|
+
}
|
|
373
|
+
const errorLabels = {
|
|
374
|
+
'404': '404 NOT FOUND',
|
|
375
|
+
'410': '410 GONE',
|
|
376
|
+
'429': '429 TRY LATER',
|
|
377
|
+
'500': '500 ERROR',
|
|
378
|
+
}
|
|
379
|
+
const emoji = errorEmojis[code] || '❌'
|
|
380
|
+
statusText = `${emoji} ${errorLabels[code] || code}`
|
|
381
|
+
statusColor = (s) => chalk.red(s)
|
|
382
|
+
} else {
|
|
383
|
+
statusText = '?'
|
|
384
|
+
statusColor = (s) => chalk.dim(s)
|
|
385
|
+
}
|
|
386
|
+
const status = statusColor(padEndDisplay(statusText, W_STATUS))
|
|
387
|
+
|
|
388
|
+
// 📖 Verdict column - use getVerdict() for stability-aware verdicts, then render with emoji
|
|
389
|
+
const verdict = getVerdict(r)
|
|
390
|
+
let verdictText, verdictColor
|
|
391
|
+
// 📖 Verdict colors follow the same green→red gradient as TIER_COLOR / SWE%
|
|
392
|
+
switch (verdict) {
|
|
393
|
+
case 'Perfect':
|
|
394
|
+
verdictText = 'Perfect 🚀'
|
|
395
|
+
verdictColor = (s) => chalk.bold.rgb(0, 255, 180)(s) // bright cyan-green — stands out from Normal
|
|
396
|
+
break
|
|
397
|
+
case 'Normal':
|
|
398
|
+
verdictText = 'Normal ✅'
|
|
399
|
+
verdictColor = (s) => chalk.bold.rgb(140, 200, 0)(s) // lime-yellow — clearly warmer than Perfect
|
|
400
|
+
break
|
|
401
|
+
case 'Spiky':
|
|
402
|
+
verdictText = 'Spiky 📈'
|
|
403
|
+
verdictColor = (s) => chalk.bold.rgb(170, 210, 0)(s) // A+ yellow-green
|
|
404
|
+
break
|
|
405
|
+
case 'Slow':
|
|
406
|
+
verdictText = 'Slow 🐢'
|
|
407
|
+
verdictColor = (s) => chalk.bold.rgb(255, 130, 0)(s) // A- amber
|
|
408
|
+
break
|
|
409
|
+
case 'Very Slow':
|
|
410
|
+
verdictText = 'Very Slow 🐌'
|
|
411
|
+
verdictColor = (s) => chalk.bold.rgb(255, 70, 0)(s) // B+ orange-red
|
|
412
|
+
break
|
|
413
|
+
case 'Overloaded':
|
|
414
|
+
verdictText = 'Overloaded 🔥'
|
|
415
|
+
verdictColor = (s) => chalk.bold.rgb(210, 20, 0)(s) // B red
|
|
416
|
+
break
|
|
417
|
+
case 'Unstable':
|
|
418
|
+
verdictText = 'Unstable ⚠️'
|
|
419
|
+
verdictColor = (s) => chalk.bold.rgb(175, 10, 0)(s) // between B and C
|
|
420
|
+
break
|
|
421
|
+
case 'Not Active':
|
|
422
|
+
verdictText = 'Not Active 👻'
|
|
423
|
+
verdictColor = (s) => chalk.dim(s)
|
|
424
|
+
break
|
|
425
|
+
case 'Pending':
|
|
426
|
+
verdictText = 'Pending ⏳'
|
|
427
|
+
verdictColor = (s) => chalk.dim(s)
|
|
428
|
+
break
|
|
429
|
+
default:
|
|
430
|
+
verdictText = 'Unusable 💀'
|
|
431
|
+
verdictColor = (s) => chalk.bold.rgb(140, 0, 0)(s) // C dark red
|
|
432
|
+
break
|
|
433
|
+
}
|
|
434
|
+
// 📖 Use padEndDisplay to account for emoji display width (2 cols each) so all rows align
|
|
435
|
+
const speedCell = verdictColor(padEndDisplay(verdictText, W_VERDICT))
|
|
436
|
+
|
|
437
|
+
// 📖 Stability column - composite score (0–100) from p95 + jitter + spikes + uptime
|
|
438
|
+
// 📖 Left-aligned to sit flush under the column header
|
|
439
|
+
const stabScore = getStabilityScore(r)
|
|
440
|
+
let stabCell
|
|
441
|
+
if (stabScore < 0) {
|
|
442
|
+
stabCell = chalk.dim('———'.padEnd(W_STAB))
|
|
443
|
+
} else if (stabScore >= 80) {
|
|
444
|
+
stabCell = chalk.greenBright(String(stabScore).padEnd(W_STAB))
|
|
445
|
+
} else if (stabScore >= 60) {
|
|
446
|
+
stabCell = chalk.cyan(String(stabScore).padEnd(W_STAB))
|
|
447
|
+
} else if (stabScore >= 40) {
|
|
448
|
+
stabCell = chalk.yellow(String(stabScore).padEnd(W_STAB))
|
|
449
|
+
} else {
|
|
450
|
+
stabCell = chalk.red(String(stabScore).padEnd(W_STAB))
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 📖 Uptime column - percentage of successful pings
|
|
454
|
+
// 📖 Left-aligned to sit flush under the column header
|
|
455
|
+
const uptimePercent = getUptime(r)
|
|
456
|
+
const uptimeStr = uptimePercent + '%'
|
|
457
|
+
let uptimeCell
|
|
458
|
+
if (uptimePercent >= 90) {
|
|
459
|
+
uptimeCell = chalk.greenBright(uptimeStr.padEnd(W_UPTIME))
|
|
460
|
+
} else if (uptimePercent >= 70) {
|
|
461
|
+
uptimeCell = chalk.yellow(uptimeStr.padEnd(W_UPTIME))
|
|
462
|
+
} else if (uptimePercent >= 50) {
|
|
463
|
+
uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.padEnd(W_UPTIME)) // orange
|
|
464
|
+
} else {
|
|
465
|
+
uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// 📖 When cursor is on this row, render Model and Provider in bright white for readability
|
|
469
|
+
const nameCell = isCursor ? chalk.white.bold(favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)) : name
|
|
470
|
+
const sourceCursorText = providerName.padEnd(W_SOURCE)
|
|
471
|
+
const sourceCell = isCursor ? chalk.white.bold(sourceCursorText) : source
|
|
472
|
+
|
|
473
|
+
// 📖 Usage column — provider-scoped remaining quota when measurable,
|
|
474
|
+
// 📖 otherwise a green dot to show "usable but not meaningfully quantifiable".
|
|
475
|
+
let usageCell
|
|
476
|
+
if (r.usagePercent !== undefined && r.usagePercent !== null) {
|
|
477
|
+
const usageStr = Math.round(r.usagePercent) + '%'
|
|
478
|
+
if (r.usagePercent >= 80) {
|
|
479
|
+
usageCell = chalk.greenBright(usageStr.padEnd(W_USAGE))
|
|
480
|
+
} else if (r.usagePercent >= 50) {
|
|
481
|
+
usageCell = chalk.yellow(usageStr.padEnd(W_USAGE))
|
|
482
|
+
} else if (r.usagePercent >= 20) {
|
|
483
|
+
usageCell = chalk.rgb(255, 165, 0)(usageStr.padEnd(W_USAGE)) // orange
|
|
484
|
+
} else {
|
|
485
|
+
usageCell = chalk.red(usageStr.padEnd(W_USAGE))
|
|
486
|
+
}
|
|
487
|
+
} else {
|
|
488
|
+
const usagePlaceholder = usagePlaceholderForProvider(r.providerKey)
|
|
489
|
+
usageCell = usagePlaceholder === '🟢'
|
|
490
|
+
? chalk.greenBright(usagePlaceholder.padEnd(W_USAGE))
|
|
491
|
+
: chalk.dim(usagePlaceholder.padEnd(W_USAGE))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// 📖 Used column — total historical prompt+completion tokens consumed for this
|
|
495
|
+
// 📖 exact provider/model pair, loaded once from request-log.jsonl at startup.
|
|
496
|
+
const tokenTotal = Number(r.totalTokens) || 0
|
|
497
|
+
const tokensCell = tokenTotal > 0
|
|
498
|
+
? chalk.rgb(120, 210, 255)(formatTokenTotalCompact(tokenTotal).padEnd(W_TOKENS))
|
|
499
|
+
: chalk.dim('0'.padEnd(W_TOKENS))
|
|
500
|
+
|
|
501
|
+
// 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used, Usage)
|
|
502
|
+
const row = ' ' + num + ' ' + tier + ' ' + sweCell + ' ' + ctxCell + ' ' + nameCell + ' ' + sourceCell + ' ' + pingCell + ' ' + avgCell + ' ' + status + ' ' + speedCell + ' ' + stabCell + ' ' + uptimeCell + ' ' + tokensCell + ' ' + usageCell
|
|
503
|
+
|
|
504
|
+
if (isCursor) {
|
|
505
|
+
lines.push(chalk.bgRgb(50, 0, 60)(row))
|
|
506
|
+
} else if (r.isRecommended) {
|
|
507
|
+
// 📖 Medium green background for recommended models (distinguishable from favorites)
|
|
508
|
+
lines.push(chalk.bgRgb(15, 40, 15)(row))
|
|
509
|
+
} else if (r.isFavorite) {
|
|
510
|
+
lines.push(chalk.bgRgb(35, 20, 0)(row))
|
|
511
|
+
} else {
|
|
512
|
+
lines.push(row)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (vp.hasBelow) {
|
|
517
|
+
lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 📖 Profile save inline prompt — shown when Shift+S is pressed, replaces spacer line
|
|
521
|
+
if (profileSaveMode) {
|
|
522
|
+
lines.push(chalk.bgRgb(40, 20, 60)(` 📋 Save profile as: ${chalk.cyanBright(profileSaveBuffer + '▏')} ${chalk.dim('Enter save • Esc cancel')}`))
|
|
523
|
+
} else {
|
|
524
|
+
lines.push('')
|
|
525
|
+
}
|
|
526
|
+
const intervalSec = Math.round(pingInterval / 1000)
|
|
527
|
+
|
|
528
|
+
// 📖 Footer hints adapt based on active mode
|
|
529
|
+
const actionHint = mode === 'openclaw'
|
|
530
|
+
? chalk.rgb(255, 100, 50)('Enter→SetOpenClaw')
|
|
531
|
+
: mode === 'opencode-desktop'
|
|
532
|
+
? chalk.rgb(0, 200, 255)('Enter→OpenDesktop')
|
|
533
|
+
: chalk.rgb(0, 200, 255)('Enter→OpenCode')
|
|
534
|
+
// 📖 Line 1: core navigation + sorting shortcuts
|
|
535
|
+
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/`) + chalk.yellow('G') + chalk.dim(` Sort • `) + chalk.yellow('T') + chalk.dim(` Tier • `) + chalk.yellow('D') + chalk.dim(` Provider • W↓/=↑ (${intervalSec}s) • `) + chalk.rgb(255, 100, 50).bold('Z') + chalk.dim(` Mode • `) + chalk.yellow('X') + chalk.dim(` Logs • `) + chalk.yellow('P') + chalk.dim(` Settings • `) + chalk.rgb(0, 255, 80).bold('K') + chalk.dim(` Help`))
|
|
536
|
+
// 📖 Line 2: profiles, recommend, feature request, bug report, and extended hints — gives visibility to less-obvious features
|
|
537
|
+
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.rgb(57, 255, 20).bold('J') + chalk.dim(` Request feature • `) + chalk.rgb(255, 87, 51).bold('I') + chalk.dim(` Report bug • `) + chalk.yellow('Esc') + chalk.dim(` Close overlay`))
|
|
538
|
+
// 📖 Proxy status line — always rendered with explicit state (starting/running/failed/stopped)
|
|
539
|
+
lines.push(renderProxyStatusLine(proxyStartupStatus, activeProxyRef))
|
|
540
|
+
lines.push(
|
|
541
|
+
chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
|
|
542
|
+
chalk.dim(' • ') +
|
|
543
|
+
'⭐ ' +
|
|
544
|
+
chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
|
|
545
|
+
chalk.dim(' • ') +
|
|
546
|
+
'🤝 ' +
|
|
547
|
+
chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
|
|
548
|
+
chalk.dim(' • ') +
|
|
549
|
+
'💬 ' +
|
|
550
|
+
chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/5MbTnDC3Md\x1b\\Discord\x1b]8;;\x1b\\') +
|
|
551
|
+
chalk.dim(' → ') +
|
|
552
|
+
chalk.rgb(200, 150, 255)('https://discord.gg/5MbTnDC3Md') +
|
|
553
|
+
chalk.dim(' • ') +
|
|
554
|
+
chalk.dim(`v${LOCAL_VERSION}`) +
|
|
555
|
+
chalk.dim(' • ') +
|
|
556
|
+
chalk.dim('Ctrl+C Exit')
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
// 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous
|
|
560
|
+
// 📖 frames are cleared. Then pad with blank cleared lines to fill the terminal,
|
|
561
|
+
// 📖 preventing stale content from lingering at the bottom after resize.
|
|
562
|
+
const EL = '\x1b[K'
|
|
563
|
+
const cleared = lines.map(l => l + EL)
|
|
564
|
+
const remaining = terminalRows > 0 ? Math.max(0, terminalRows - cleared.length) : 0
|
|
565
|
+
for (let i = 0; i < remaining; i++) cleared.push(EL)
|
|
566
|
+
return cleared.join('\n')
|
|
567
|
+
}
|