free-coding-models 0.1.83 → 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.
@@ -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
+ }
package/src/setup.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file setup.js
3
+ * @description First-run API key setup wizard, extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * `promptApiKey` is the interactive first-time setup wizard shown when NO provider has
7
+ * a key configured yet. It steps through every configured provider in `sources.js`
8
+ * sequentially, displaying the signup URL and a hint, then asks the user to paste their
9
+ * key (pressing Enter skips that provider).
10
+ *
11
+ * The wizard is skipped on subsequent runs because `loadConfig()` finds existing keys in
12
+ * ~/.free-coding-models.json and the caller (`main()`) only invokes `promptApiKey` when
13
+ * `Object.values(config.apiKeys).every(v => !v)`.
14
+ *
15
+ * ⚙️ How it works:
16
+ * 1. Builds a `providers` list from `Object.keys(sources)` so new providers added to
17
+ * sources.js automatically appear in the wizard without any code changes here.
18
+ * 2. Uses `readline.createInterface` for line-at-a-time input (not raw mode).
19
+ * 3. Calls `saveConfig(config)` once after collecting all answers.
20
+ * 4. Returns the nvidia key (or the first entered key) for backward-compatibility with
21
+ * the `main()` caller that originally checked for `nvidiKey !== null` before continuing.
22
+ *
23
+ * @functions
24
+ * → promptApiKey(config) — Interactive multi-provider key wizard; returns first found key or null
25
+ *
26
+ * @exports
27
+ * promptApiKey
28
+ *
29
+ * @see src/provider-metadata.js — PROVIDER_METADATA provides label/color/url/hint per provider
30
+ * @see src/config.js — saveConfig persists the collected keys
31
+ * @see sources.js — Object.keys(sources) drives the provider iteration order
32
+ * @see bin/free-coding-models.js — calls promptApiKey when no keys are configured
33
+ */
34
+
35
+ import chalk from 'chalk'
36
+ import { createRequire } from 'module'
37
+ import { sources } from '../sources.js'
38
+ import { PROVIDER_METADATA } from './provider-metadata.js'
39
+ import { saveConfig } from './config.js'
40
+
41
+ const require = createRequire(import.meta.url)
42
+ const readline = require('readline')
43
+
44
+ /**
45
+ * 📖 promptApiKey: Interactive first-run wizard for multi-provider API key setup.
46
+ * 📖 Shown when NO provider has a key configured yet.
47
+ * 📖 Steps through all configured providers sequentially — each is optional (Enter to skip).
48
+ * 📖 At least one key must be entered to proceed. Keys saved to ~/.free-coding-models.json.
49
+ * 📖 Returns the nvidia key (or null) for backward-compat with the rest of main().
50
+ * @param {Record<string, unknown>} config
51
+ * @returns {Promise<string|null>}
52
+ */
53
+ export async function promptApiKey(config) {
54
+ console.log()
55
+ console.log(chalk.bold(' 🔑 First-time setup — API keys'))
56
+ console.log(chalk.dim(' Enter keys for any provider you want to use. Press Enter to skip one.'))
57
+ console.log()
58
+
59
+ // 📖 Build providers from sources to keep setup in sync with actual supported providers.
60
+ const providers = Object.keys(sources).map((key) => {
61
+ const meta = PROVIDER_METADATA[key] || {}
62
+ return {
63
+ key,
64
+ label: meta.label || sources[key]?.name || key,
65
+ color: meta.color || chalk.white,
66
+ url: meta.signupUrl || 'https://example.com',
67
+ hint: meta.signupHint || 'Create API key',
68
+ }
69
+ })
70
+
71
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
72
+
73
+ // 📖 Ask a single question — returns trimmed string or '' for skip
74
+ const ask = (question) => new Promise((resolve) => {
75
+ rl.question(question, (answer) => resolve(answer.trim()))
76
+ })
77
+
78
+ for (const p of providers) {
79
+ console.log(` ${p.color('●')} ${chalk.bold(p.label)}`)
80
+ console.log(chalk.dim(` Free key at: `) + chalk.cyanBright(p.url))
81
+ console.log(chalk.dim(` ${p.hint}`))
82
+ const answer = await ask(chalk.dim(` Enter key (or Enter to skip): `))
83
+ console.log()
84
+ if (answer) {
85
+ config.apiKeys[p.key] = answer
86
+ }
87
+ }
88
+
89
+ rl.close()
90
+
91
+ // 📖 Check at least one key was entered
92
+ const anyKey = Object.values(config.apiKeys).some(v => v)
93
+ if (!anyKey) {
94
+ return null
95
+ }
96
+
97
+ saveConfig(config)
98
+ const savedCount = Object.values(config.apiKeys).filter(v => v).length
99
+ console.log(chalk.green(` ✅ ${savedCount} key(s) saved to ~/.free-coding-models.json`))
100
+ console.log(chalk.dim(' You can add or change keys anytime with the ') + chalk.yellow('P') + chalk.dim(' key in the TUI.'))
101
+ console.log()
102
+
103
+ // 📖 Return nvidia key for backward-compat (main() checks it exists before continuing)
104
+ return config.apiKeys.nvidia || Object.values(config.apiKeys).find(v => v) || null
105
+ }