free-coding-models 0.1.83 → 0.1.85

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