free-coding-models 0.3.16 → 0.3.18

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.
@@ -34,7 +34,16 @@
34
34
  import chalk from 'chalk'
35
35
  import { createRequire } from 'module'
36
36
  import { sources } from '../sources.js'
37
- import { PING_INTERVAL, FRAMES } from './constants.js'
37
+ import {
38
+ TABLE_FIXED_LINES,
39
+ COL_MODEL,
40
+ TIER_CYCLE,
41
+ msCell,
42
+ spinCell,
43
+ PING_INTERVAL,
44
+ FRAMES
45
+ } from './constants.js'
46
+ import { themeColors, getProviderRgb, getTierRgb, getReadableTextRgb, getTheme } from './theme.js'
38
47
  import { TIER_COLOR } from './tier-colors.js'
39
48
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo } from './utils.js'
40
49
  import { usagePlaceholderForProvider } from './ping.js'
@@ -42,27 +51,7 @@ import { formatTokenTotalCompact } from './token-usage-reader.js'
42
51
  import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth } from './render-helpers.js'
43
52
  import { getToolMeta } from './tool-metadata.js'
44
53
  import { PROXY_DISABLED_NOTICE } from './product-flags.js'
45
-
46
- const ACTIVE_FILTER_BG_BY_TIER = {
47
- 'S+': [57, 255, 20],
48
- 'S': [57, 255, 20],
49
- 'A+': [160, 255, 60],
50
- 'A': [255, 224, 130],
51
- 'A-': [255, 204, 128],
52
- 'B+': [255, 171, 64],
53
- 'B': [239, 83, 80],
54
- 'C': [186, 104, 200],
55
- }
56
-
57
- // 📖 Import UI configuration for consistent styling
58
- import { VERTICAL_SEPARATOR, COLUMN_SPACING } from './ui-config.js';
59
-
60
- // 📖 Column separator (vertical bar) is now defined in ui-config.js
61
- // const VERTICAL_SEPARATOR = chalk.rgb(255, 140, 0).dim('│');
62
- // const COL_SEP = ` ${VERTICAL_SEPARATOR} `; // Replaced by imported COLUMN_SPACING
63
-
64
- // 📖 Column spacing is now defined in ui-config.js
65
- const COL_SEP = COLUMN_SPACING;
54
+ import { getColumnSpacing } from './ui-config.js'
66
55
 
67
56
  const require = createRequire(import.meta.url)
68
57
  const { version: LOCAL_VERSION } = require('../package.json')
@@ -70,28 +59,12 @@ const { version: LOCAL_VERSION } = require('../package.json')
70
59
  // 📖 Provider column palette: soft pastel rainbow so each provider stays easy
71
60
  // 📖 to spot without turning the table into a harsh neon wall.
72
61
  // 📖 Exported for use in overlays (settings screen) and logs.
73
- export const PROVIDER_COLOR = {
74
- nvidia: [178, 235, 190],
75
- groq: [255, 204, 188],
76
- cerebras: [179, 229, 252],
77
- sambanova: [255, 224, 178],
78
- openrouter: [225, 190, 231],
79
- huggingface: [255, 245, 157],
80
- replicate: [187, 222, 251],
81
- deepinfra: [178, 223, 219],
82
- fireworks: [255, 205, 210],
83
- codestral: [248, 187, 208],
84
- hyperbolic: [255, 171, 145],
85
- scaleway: [129, 212, 250],
86
- googleai: [187, 222, 251],
87
- siliconflow: [178, 235, 242],
88
- together: [255, 241, 118],
89
- cloudflare: [255, 204, 128],
90
- perplexity: [244, 143, 177],
91
- qwen: [255, 224, 130],
92
- zai: [174, 213, 255],
93
- iflow: [220, 231, 117],
94
- }
62
+ export const PROVIDER_COLOR = new Proxy({}, {
63
+ get(_target, providerKey) {
64
+ if (typeof providerKey !== 'string') return undefined
65
+ return getProviderRgb(providerKey)
66
+ },
67
+ })
95
68
 
96
69
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
97
70
  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, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, disableWidthsWarning = false) {
@@ -113,23 +86,23 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
113
86
 
114
87
  const intervalSec = Math.round(pingInterval / 1000)
115
88
  const pingModeMeta = {
116
- speed: { label: 'fast', color: chalk.bold.rgb(255, 210, 80) },
117
- normal: { label: 'normal', color: chalk.bold.rgb(120, 210, 255) },
118
- slow: { label: 'slow', color: chalk.bold.rgb(255, 170, 90) },
119
- forced: { label: 'forced', color: chalk.bold.rgb(255, 120, 120) },
89
+ speed: { label: 'fast', color: themeColors.warningBold },
90
+ normal: { label: 'normal', color: themeColors.accentBold },
91
+ slow: { label: 'slow', color: themeColors.info },
92
+ forced: { label: 'forced', color: themeColors.errorBold },
120
93
  }
121
94
  const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
122
95
  const pingProgressText = `${completedPings}/${totalVisible}`
123
96
  const nextCountdownColor = secondsUntilNext > 8
124
- ? chalk.red.bold
97
+ ? themeColors.errorBold
125
98
  : secondsUntilNext >= 4
126
- ? chalk.yellow.bold
99
+ ? themeColors.warningBold
127
100
  : secondsUntilNext < 1
128
- ? chalk.greenBright.bold
129
- : chalk.green.bold
101
+ ? themeColors.successBold
102
+ : themeColors.success
130
103
  const pingControlBadge =
131
104
  activePingMode.color(' [ ') +
132
- chalk.yellow.bold('W') +
105
+ themeColors.hotkey('W') +
133
106
  activePingMode.color(` Ping Interval : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - next : `) +
134
107
  nextCountdownColor(`${secondsUntilNextLabel}s`) +
135
108
  activePingMode.color(' ]')
@@ -137,11 +110,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
137
110
  // 📖 Tool badge keeps the active launch target visible in the header, so the
138
111
  // 📖 footer no longer needs a redundant Enter action or mode toggle reminder.
139
112
  const toolMeta = getToolMeta(mode)
140
- const toolBadgeColor = mode === 'openclaw'
141
- ? chalk.bold.rgb(255, 100, 50)
142
- : chalk.bold.rgb(0, 200, 255)
143
- const modeBadge = toolBadgeColor(' [ ') + chalk.yellow.bold('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
144
- const activeHeaderBadge = (text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg).bold(` ${text} `)
113
+ const toolBadgeColor = mode === 'openclaw' ? themeColors.warningBold : themeColors.accentBold
114
+ const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(` Tool : ${toolMeta.label} ]`)
115
+ const activeHeaderBadge = (text, bg) => themeColors.badge(text, bg, getReadableTextRgb(bg))
145
116
  const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion, startupLatestVersion, versionAlertsEnabled)
146
117
 
147
118
  // 📖 Tier filter badge shown when filtering is active (shows exact tier name)
@@ -150,7 +121,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
150
121
  let activeTierLabel = ''
151
122
  if (tierFilterMode > 0) {
152
123
  activeTierLabel = TIER_CYCLE_NAMES[tierFilterMode]
153
- const tierBg = ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20]
124
+ const tierBg = getTierRgb(activeTierLabel)
154
125
  tierBadge = ` ${activeHeaderBadge(`TIER (${activeTierLabel})`, tierBg)}`
155
126
  }
156
127
 
@@ -169,13 +140,12 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
169
140
  if (activeOriginName) {
170
141
  activeOriginLabel = normalizeOriginLabel(activeOriginName, activeOriginKey)
171
142
  const providerRgb = PROVIDER_COLOR[activeOriginKey] || [255, 255, 255]
172
- originBadge = ` ${activeHeaderBadge(`PROVIDER (${activeOriginLabel})`, [0, 0, 0], providerRgb)}`
143
+ originBadge = ` ${activeHeaderBadge(`PROVIDER (${activeOriginLabel})`, providerRgb)}`
173
144
  }
174
145
  }
175
146
 
176
-
177
-
178
147
  // 📖 Column widths (generous spacing with margins)
148
+ const COL_SEP = getColumnSpacing()
179
149
  const W_RANK = 6
180
150
  const W_TIER = 6
181
151
  const W_CTX = 6
@@ -206,18 +176,18 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
206
176
  const padLeft2 = Math.max(0, Math.floor((terminalCols - warning2.length) / 2))
207
177
  const padLeft3 = Math.max(0, Math.floor((terminalCols - warning3.length) / 2))
208
178
  for (let i = 0; i < blankLines; i++) lines.push('')
209
- lines.push(' '.repeat(padLeft) + chalk.red.bold(warning))
179
+ lines.push(' '.repeat(padLeft) + themeColors.errorBold(warning))
210
180
  lines.push('')
211
- lines.push(' '.repeat(padLeft2) + chalk.red(warning2))
181
+ lines.push(' '.repeat(padLeft2) + themeColors.error(warning2))
212
182
  lines.push('')
213
- lines.push(' '.repeat(padLeft3) + chalk.red(warning3))
183
+ lines.push(' '.repeat(padLeft3) + themeColors.error(warning3))
214
184
  lines.push('')
215
- lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + chalk.yellow(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
185
+ lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + themeColors.warning(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
216
186
  const barTotal = Math.max(0, Math.min(terminalCols - 4, 30))
217
187
  const barFill = Math.round((elapsed / warningDurationMs) * barTotal)
218
- const barStr = chalk.green('█'.repeat(barFill)) + chalk.dim('░'.repeat(barTotal - barFill))
188
+ const barStr = themeColors.success('█'.repeat(barFill)) + themeColors.dim('░'.repeat(barTotal - barFill))
219
189
  lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - barTotal) / 2))) + barStr)
220
- lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + chalk.dim('press esc to dismiss'))
190
+ lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + themeColors.dim('press esc to dismiss'))
221
191
  while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
222
192
  const EL = '\x1b[K'
223
193
  return lines.map(line => line + EL).join('\n')
@@ -227,11 +197,11 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
227
197
  const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
228
198
 
229
199
  const lines = [
230
- ` ${chalk.cyanBright.bold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
231
- chalk.dim('📦 ') + chalk.cyanBright.bold(`${completedPings}/${totalVisible}`) + chalk.dim(' ') +
232
- chalk.greenBright(`✅ ${up}`) + chalk.dim(' up ') +
233
- chalk.yellow(`⏳ ${timeout}`) + chalk.dim(' timeout ') +
234
- chalk.red(`❌ ${down}`) + chalk.dim(' down ') +
200
+ ` ${themeColors.accentBold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
201
+ themeColors.dim('📦 ') + themeColors.accentBold(`${completedPings}/${totalVisible}`) + themeColors.dim(' ') +
202
+ themeColors.success(`✅ ${up}`) + themeColors.dim(' up ') +
203
+ themeColors.warning(`⏳ ${timeout}`) + themeColors.dim(' timeout ') +
204
+ themeColors.error(`❌ ${down}`) + themeColors.dim(' down ') +
235
205
  '',
236
206
  '',
237
207
  ]
@@ -254,16 +224,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
254
224
  const stabH = sortColumn === 'stability' ? dir + ' Stability' : 'Stability'
255
225
  const uptimeH = sortColumn === 'uptime' ? dir + ' Up%' : 'Up%'
256
226
  const tokensH = 'Used'
257
- const usageH = sortColumn === 'usage' ? dir + ' Usage' : 'Usage'
258
227
 
259
228
  // 📖 Helper to colorize first letter for keyboard shortcuts
260
229
  // 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
261
- const colorFirst = (text, width, colorFn = chalk.yellow) => {
230
+ const colorFirst = (text, width, colorFn = themeColors.hotkey) => {
262
231
  const first = text[0]
263
232
  const rest = text.slice(1)
264
233
  const plainText = first + rest
265
234
  const padding = ' '.repeat(Math.max(0, width - plainText.length))
266
- return colorFn(first) + chalk.dim(rest + padding)
235
+ return colorFn(first) + themeColors.dim(rest + padding)
267
236
  }
268
237
 
269
238
  // 📖 Now colorize after padding is calculated on plain text
@@ -271,33 +240,33 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
271
240
  const tierH_c = colorFirst('Tier', W_TIER)
272
241
  const originLabel = 'Provider'
273
242
  const originH_c = sortColumn === 'origin'
274
- ? chalk.bold.cyan(originLabel.padEnd(W_SOURCE))
275
- : (originFilterMode > 0 ? chalk.bold.rgb(100, 200, 255)(originLabel.padEnd(W_SOURCE)) : (() => {
243
+ ? themeColors.accentBold(originLabel.padEnd(W_SOURCE))
244
+ : (originFilterMode > 0 ? themeColors.accentBold(originLabel.padEnd(W_SOURCE)) : (() => {
276
245
  // 📖 Provider keeps O for sorting and D for provider-filter cycling.
277
246
  const plain = 'PrOviDer'
278
247
  const padding = ' '.repeat(Math.max(0, W_SOURCE - plain.length))
279
- return chalk.dim('Pr') + chalk.yellow.bold('O') + chalk.dim('vi') + chalk.yellow.bold('D') + chalk.dim('er' + padding)
248
+ return themeColors.dim('Pr') + themeColors.hotkey('O') + themeColors.dim('vi') + themeColors.hotkey('D') + themeColors.dim('er' + padding)
280
249
  })())
281
250
  const modelH_c = colorFirst(modelH, W_MODEL)
282
- const sweH_c = sortColumn === 'swe' ? chalk.bold.cyan(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
283
- const ctxH_c = sortColumn === 'ctx' ? chalk.bold.cyan(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
284
- const pingH_c = sortColumn === 'ping' ? chalk.bold.cyan(pingH.padEnd(W_PING)) : colorFirst('Latest Ping', W_PING)
285
- const avgH_c = sortColumn === 'avg' ? chalk.bold.cyan(avgH.padEnd(W_AVG)) : colorFirst('Avg Ping', W_AVG)
286
- const healthH_c = sortColumn === 'condition' ? chalk.bold.cyan(healthH.padEnd(W_STATUS)) : colorFirst('Health', W_STATUS)
287
- const verdictH_c = sortColumn === 'verdict' ? chalk.bold.cyan(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
251
+ const sweH_c = sortColumn === 'swe' ? themeColors.accentBold(sweH.padEnd(W_SWE)) : colorFirst(sweH, W_SWE)
252
+ const ctxH_c = sortColumn === 'ctx' ? themeColors.accentBold(ctxH.padEnd(W_CTX)) : colorFirst(ctxH, W_CTX)
253
+ const pingH_c = sortColumn === 'ping' ? themeColors.accentBold(pingH.padEnd(W_PING)) : colorFirst('Latest Ping', W_PING)
254
+ const avgH_c = sortColumn === 'avg' ? themeColors.accentBold(avgH.padEnd(W_AVG)) : colorFirst('Avg Ping', W_AVG)
255
+ const healthH_c = sortColumn === 'condition' ? themeColors.accentBold(healthH.padEnd(W_STATUS)) : colorFirst('Health', W_STATUS)
256
+ const verdictH_c = sortColumn === 'verdict' ? themeColors.accentBold(verdictH.padEnd(W_VERDICT)) : colorFirst(verdictH, W_VERDICT)
288
257
  // 📖 Custom colorization for Stability: highlight 'B' (the sort key) since 'S' is taken by SWE
289
- const stabH_c = sortColumn === 'stability' ? chalk.bold.cyan(stabH.padEnd(W_STAB)) : (() => {
258
+ const stabH_c = sortColumn === 'stability' ? themeColors.accentBold(stabH.padEnd(W_STAB)) : (() => {
290
259
  const plain = 'Stability'
291
260
  const padding = ' '.repeat(Math.max(0, W_STAB - plain.length))
292
- return chalk.dim('Sta') + chalk.yellow.bold('B') + chalk.dim('ility' + padding)
261
+ return themeColors.dim('Sta') + themeColors.hotkey('B') + themeColors.dim('ility' + padding)
293
262
  })()
294
263
  // 📖 Up% sorts on U, so keep the highlighted shortcut in the shared yellow sort-key color.
295
- const uptimeH_c = sortColumn === 'uptime' ? chalk.bold.cyan(uptimeH.padEnd(W_UPTIME)) : (() => {
264
+ const uptimeH_c = sortColumn === 'uptime' ? themeColors.accentBold(uptimeH.padEnd(W_UPTIME)) : (() => {
296
265
  const plain = 'Up%'
297
266
  const padding = ' '.repeat(Math.max(0, W_UPTIME - plain.length))
298
- return chalk.yellow.bold('U') + chalk.dim('p%' + padding)
267
+ return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
299
268
  })()
300
- const tokensH_c = chalk.dim(tokensH.padEnd(W_TOKENS))
269
+ const tokensH_c = themeColors.dim(tokensH.padEnd(W_TOKENS))
301
270
  // 📖 Usage column removed from UI – no header or separator for it.
302
271
  // Header without Usage column (column order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
303
272
  lines.push(' ' + rankH_c + COL_SEP + tierH_c + COL_SEP + sweH_c + COL_SEP + ctxH_c + COL_SEP + modelH_c + COL_SEP + originH_c + COL_SEP + pingH_c + COL_SEP + avgH_c + COL_SEP + healthH_c + COL_SEP + verdictH_c + COL_SEP + stabH_c + COL_SEP + uptimeH_c + COL_SEP + tokensH_c)
@@ -307,42 +276,50 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
307
276
  if (sorted.length === 0) {
308
277
  lines.push('')
309
278
  if (hideUnconfiguredModels) {
310
- lines.push(` ${chalk.redBright.bold('Press P to configure your API key.')}`)
311
- lines.push(` ${chalk.dim('No configured provider currently exposes visible models in the table.')}`)
279
+ lines.push(` ${themeColors.errorBold('Press P to configure your API key.')}`)
280
+ lines.push(` ${themeColors.dim('No configured provider currently exposes visible models in the table.')}`)
312
281
  } else {
313
- lines.push(` ${chalk.yellow.bold('No models match the current filters.')}`)
282
+ lines.push(` ${themeColors.warningBold('No models match the current filters.')}`)
314
283
  }
315
284
  }
316
285
 
317
286
  // 📖 Viewport clipping: only render models that fit on screen
318
287
  const extraFooterLines = versionStatus.isOutdated ? 1 : 0
319
288
  const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
289
+ const paintSweScore = (score, paddedText) => {
290
+ if (score >= 70) return chalk.bold.rgb(...getTierRgb('S+'))(paddedText)
291
+ if (score >= 60) return chalk.bold.rgb(...getTierRgb('S'))(paddedText)
292
+ if (score >= 50) return chalk.bold.rgb(...getTierRgb('A+'))(paddedText)
293
+ if (score >= 40) return chalk.rgb(...getTierRgb('A'))(paddedText)
294
+ if (score >= 35) return chalk.rgb(...getTierRgb('A-'))(paddedText)
295
+ if (score >= 30) return chalk.rgb(...getTierRgb('B+'))(paddedText)
296
+ if (score >= 20) return chalk.rgb(...getTierRgb('B'))(paddedText)
297
+ return chalk.rgb(...getTierRgb('C'))(paddedText)
298
+ }
320
299
 
321
300
  if (vp.hasAbove) {
322
- lines.push(chalk.dim(` ... ${vp.startIdx} more above ...`))
301
+ lines.push(themeColors.dim(` ... ${vp.startIdx} more above ...`))
323
302
  }
324
303
 
325
304
  for (let i = vp.startIdx; i < vp.endIdx; i++) {
326
305
  const r = sorted[i]
327
- const tierFn = TIER_COLOR[r.tier] ?? (t => chalk.white(t))
306
+ const tierFn = TIER_COLOR[r.tier] ?? ((text) => themeColors.text(text))
328
307
 
329
308
  const isCursor = cursor !== null && i === cursor
330
309
 
331
310
  // 📖 Left-aligned columns - pad plain text first, then colorize
332
- const num = chalk.dim(String(r.idx).padEnd(W_RANK))
311
+ const num = themeColors.dim(String(r.idx).padEnd(W_RANK))
333
312
  const tier = tierFn(r.tier.padEnd(W_TIER))
334
313
  // 📖 Keep terminal view provider-specific so each row is monitorable per provider
335
314
  const providerNameRaw = sources[r.providerKey]?.name ?? r.providerKey ?? 'NIM'
336
315
  const providerName = normalizeOriginLabel(providerNameRaw, r.providerKey)
337
- const providerRgb = PROVIDER_COLOR[r.providerKey] ?? [105, 190, 245]
338
- const source = chalk.rgb(...providerRgb)(providerName.padEnd(W_SOURCE))
316
+ const source = themeColors.provider(r.providerKey, providerName.padEnd(W_SOURCE))
339
317
  // 📖 Favorites: always reserve 2 display columns at the start of Model column.
340
318
  // 📖 🎯 (2 cols) for recommended, ⭐ (2 cols) for favorites, ' ' (2 spaces) for non-favorites — keeps alignment stable.
341
319
  const favoritePrefix = r.isRecommended ? '🎯' : r.isFavorite ? '⭐' : ' '
342
320
  const prefixDisplayWidth = 2
343
321
  const nameWidth = Math.max(0, W_MODEL - prefixDisplayWidth)
344
322
  const name = favoritePrefix + r.label.slice(0, nameWidth).padEnd(nameWidth)
345
- const modelColor = chalk.rgb(...providerRgb)
346
323
  const sweScore = r.sweScore ?? '—'
347
324
  // 📖 SWE% colorized on the same gradient as Tier:
348
325
  // ≥70% bright neon green (S+), ≥60% green (S), ≥50% yellow-green (A+),
@@ -350,27 +327,20 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
350
327
  // ≥20% red (B), <20% dark red (C), '—' dim
351
328
  let sweCell
352
329
  if (sweScore === '—') {
353
- sweCell = chalk.dim(sweScore.padEnd(W_SWE))
330
+ sweCell = themeColors.dim(sweScore.padEnd(W_SWE))
354
331
  } else {
355
332
  const sweVal = parseFloat(sweScore)
356
333
  const swePadded = sweScore.padEnd(W_SWE)
357
- if (sweVal >= 70) sweCell = chalk.bold.rgb(0, 255, 80)(swePadded)
358
- else if (sweVal >= 60) sweCell = chalk.bold.rgb(80, 220, 0)(swePadded)
359
- else if (sweVal >= 50) sweCell = chalk.bold.rgb(170, 210, 0)(swePadded)
360
- else if (sweVal >= 40) sweCell = chalk.rgb(240, 190, 0)(swePadded)
361
- else if (sweVal >= 35) sweCell = chalk.rgb(255, 130, 0)(swePadded)
362
- else if (sweVal >= 30) sweCell = chalk.rgb(255, 70, 0)(swePadded)
363
- else if (sweVal >= 20) sweCell = chalk.rgb(210, 20, 0)(swePadded)
364
- else sweCell = chalk.rgb(140, 0, 0)(swePadded)
334
+ sweCell = paintSweScore(sweVal, swePadded)
365
335
  }
366
336
 
367
337
  // 📖 Context window column - colorized by size (larger = better)
368
338
  const ctxRaw = r.ctx ?? '—'
369
339
  const ctxCell = ctxRaw !== '—' && (ctxRaw.includes('128k') || ctxRaw.includes('200k') || ctxRaw.includes('1m'))
370
- ? chalk.greenBright(ctxRaw.padEnd(W_CTX))
340
+ ? themeColors.metricGood(ctxRaw.padEnd(W_CTX))
371
341
  : ctxRaw !== '—' && (ctxRaw.includes('32k') || ctxRaw.includes('64k'))
372
- ? chalk.cyan(ctxRaw.padEnd(W_CTX))
373
- : chalk.dim(ctxRaw.padEnd(W_CTX))
342
+ ? themeColors.metricOk(ctxRaw.padEnd(W_CTX))
343
+ : themeColors.dim(ctxRaw.padEnd(W_CTX))
374
344
 
375
345
  // 📖 Keep the row-local spinner small and inline so users can still read the last measured latency.
376
346
  const buildLatestPingDisplay = (value) => {
@@ -384,18 +354,18 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
384
354
  let pingCell
385
355
  if (!latestPing) {
386
356
  const placeholder = r.isPinging ? buildLatestPingDisplay('———') : '———'.padEnd(W_PING)
387
- pingCell = chalk.dim(placeholder)
357
+ pingCell = themeColors.dim(placeholder)
388
358
  } else if (latestPing.code === '200') {
389
359
  // 📖 Success - show response time
390
360
  const str = buildLatestPingDisplay(String(latestPing.ms))
391
- pingCell = latestPing.ms < 500 ? chalk.greenBright(str) : latestPing.ms < 1500 ? chalk.yellow(str) : chalk.red(str)
361
+ pingCell = latestPing.ms < 500 ? themeColors.metricGood(str) : latestPing.ms < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
392
362
  } else if (latestPing.code === '401') {
393
363
  // 📖 401 = no API key but server IS reachable — still show latency in dim
394
- pingCell = chalk.dim(buildLatestPingDisplay(String(latestPing.ms)))
364
+ pingCell = themeColors.dim(buildLatestPingDisplay(String(latestPing.ms)))
395
365
  } else {
396
366
  // 📖 Error or timeout - show "———" (error code is already in Status column)
397
367
  const placeholder = r.isPinging ? buildLatestPingDisplay('———') : '———'.padEnd(W_PING)
398
- pingCell = chalk.dim(placeholder)
368
+ pingCell = themeColors.dim(placeholder)
399
369
  }
400
370
 
401
371
  // 📖 Avg ping (just number, no "ms")
@@ -403,9 +373,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
403
373
  let avgCell
404
374
  if (avg !== Infinity) {
405
375
  const str = String(avg).padEnd(W_AVG)
406
- avgCell = avg < 500 ? chalk.greenBright(str) : avg < 1500 ? chalk.yellow(str) : chalk.red(str)
376
+ avgCell = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
407
377
  } else {
408
- avgCell = chalk.dim('———'.padEnd(W_AVG))
378
+ avgCell = themeColors.dim('———'.padEnd(W_AVG))
409
379
  }
410
380
 
411
381
  // 📖 Status column - build plain text with emoji, pad, then colorize
@@ -414,21 +384,21 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
414
384
  if (r.status === 'noauth') {
415
385
  // 📖 Server responded but needs an API key — shown dimly since it IS reachable
416
386
  statusText = `🔑 NO KEY`
417
- statusColor = (s) => chalk.dim(s)
387
+ statusColor = themeColors.dim
418
388
  } else if (r.status === 'auth_error') {
419
389
  // 📖 A key is configured but the provider rejected it — keep this distinct
420
390
  // 📖 from "no key" so configured-only mode does not look misleading.
421
391
  statusText = `🔐 AUTH FAIL`
422
- statusColor = (s) => chalk.redBright(s)
392
+ statusColor = themeColors.errorBold
423
393
  } else if (r.status === 'pending') {
424
394
  statusText = `${FRAMES[frame % FRAMES.length]} wait`
425
- statusColor = (s) => chalk.dim.yellow(s)
395
+ statusColor = themeColors.warning
426
396
  } else if (r.status === 'up') {
427
397
  statusText = `✅ UP`
428
- statusColor = (s) => s
398
+ statusColor = themeColors.success
429
399
  } else if (r.status === 'timeout') {
430
400
  statusText = `⏳ TIMEOUT`
431
- statusColor = (s) => chalk.yellow(s)
401
+ statusColor = themeColors.warning
432
402
  } else if (r.status === 'down') {
433
403
  const code = r.httpCode ?? 'ERR'
434
404
  // 📖 Different emojis for different error codes
@@ -448,10 +418,10 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
448
418
  }
449
419
  const emoji = errorEmojis[code] || '❌'
450
420
  statusText = `${emoji} ${errorLabels[code] || code}`
451
- statusColor = (s) => chalk.red(s)
421
+ statusColor = themeColors.error
452
422
  } else {
453
423
  statusText = '?'
454
- statusColor = (s) => chalk.dim(s)
424
+ statusColor = themeColors.dim
455
425
  }
456
426
  const status = statusColor(padEndDisplay(statusText, W_STATUS))
457
427
 
@@ -462,43 +432,43 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
462
432
  switch (verdict) {
463
433
  case 'Perfect':
464
434
  verdictText = 'Perfect 🚀'
465
- verdictColor = (s) => chalk.bold.rgb(0, 255, 180)(s) // bright cyan-green — stands out from Normal
435
+ verdictColor = themeColors.successBold
466
436
  break
467
437
  case 'Normal':
468
438
  verdictText = 'Normal ✅'
469
- verdictColor = (s) => chalk.bold.rgb(140, 200, 0)(s) // lime-yellow — clearly warmer than Perfect
439
+ verdictColor = themeColors.metricGood
470
440
  break
471
441
  case 'Spiky':
472
442
  verdictText = 'Spiky 📈'
473
- verdictColor = (s) => chalk.bold.rgb(170, 210, 0)(s) // A+ yellow-green
443
+ verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A+'))(text)
474
444
  break
475
445
  case 'Slow':
476
446
  verdictText = 'Slow 🐢'
477
- verdictColor = (s) => chalk.bold.rgb(255, 130, 0)(s) // A- amber
447
+ verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A-'))(text)
478
448
  break
479
449
  case 'Very Slow':
480
450
  verdictText = 'Very Slow 🐌'
481
- verdictColor = (s) => chalk.bold.rgb(255, 70, 0)(s) // B+ orange-red
451
+ verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B+'))(text)
482
452
  break
483
453
  case 'Overloaded':
484
454
  verdictText = 'Overloaded 🔥'
485
- verdictColor = (s) => chalk.bold.rgb(210, 20, 0)(s) // B red
455
+ verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B'))(text)
486
456
  break
487
457
  case 'Unstable':
488
458
  verdictText = 'Unstable ⚠️'
489
- verdictColor = (s) => chalk.bold.rgb(175, 10, 0)(s) // between B and C
459
+ verdictColor = themeColors.errorBold
490
460
  break
491
461
  case 'Not Active':
492
462
  verdictText = 'Not Active 👻'
493
- verdictColor = (s) => chalk.dim(s)
463
+ verdictColor = themeColors.dim
494
464
  break
495
465
  case 'Pending':
496
466
  verdictText = 'Pending ⏳'
497
- verdictColor = (s) => chalk.dim(s)
467
+ verdictColor = themeColors.dim
498
468
  break
499
469
  default:
500
470
  verdictText = 'Unusable 💀'
501
- verdictColor = (s) => chalk.bold.rgb(140, 0, 0)(s) // C dark red
471
+ verdictColor = (text) => chalk.bold.rgb(...getTierRgb('C'))(text)
502
472
  break
503
473
  }
504
474
  // 📖 Use padEndDisplay to account for emoji display width (2 cols each) so all rows align
@@ -509,15 +479,15 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
509
479
  const stabScore = getStabilityScore(r)
510
480
  let stabCell
511
481
  if (stabScore < 0) {
512
- stabCell = chalk.dim('———'.padEnd(W_STAB))
482
+ stabCell = themeColors.dim('———'.padEnd(W_STAB))
513
483
  } else if (stabScore >= 80) {
514
- stabCell = chalk.greenBright(String(stabScore).padEnd(W_STAB))
484
+ stabCell = themeColors.metricGood(String(stabScore).padEnd(W_STAB))
515
485
  } else if (stabScore >= 60) {
516
- stabCell = chalk.cyan(String(stabScore).padEnd(W_STAB))
486
+ stabCell = themeColors.metricOk(String(stabScore).padEnd(W_STAB))
517
487
  } else if (stabScore >= 40) {
518
- stabCell = chalk.yellow(String(stabScore).padEnd(W_STAB))
488
+ stabCell = themeColors.metricWarn(String(stabScore).padEnd(W_STAB))
519
489
  } else {
520
- stabCell = chalk.red(String(stabScore).padEnd(W_STAB))
490
+ stabCell = themeColors.metricBad(String(stabScore).padEnd(W_STAB))
521
491
  }
522
492
 
523
493
  // 📖 Uptime column - percentage of successful pings
@@ -526,20 +496,20 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
526
496
  const uptimeStr = uptimePercent + '%'
527
497
  let uptimeCell
528
498
  if (uptimePercent >= 90) {
529
- uptimeCell = chalk.greenBright(uptimeStr.padEnd(W_UPTIME))
499
+ uptimeCell = themeColors.metricGood(uptimeStr.padEnd(W_UPTIME))
530
500
  } else if (uptimePercent >= 70) {
531
- uptimeCell = chalk.yellow(uptimeStr.padEnd(W_UPTIME))
501
+ uptimeCell = themeColors.metricWarn(uptimeStr.padEnd(W_UPTIME))
532
502
  } else if (uptimePercent >= 50) {
533
- uptimeCell = chalk.rgb(255, 165, 0)(uptimeStr.padEnd(W_UPTIME)) // orange
503
+ uptimeCell = chalk.rgb(...getTierRgb('A-'))(uptimeStr.padEnd(W_UPTIME))
534
504
  } else {
535
- uptimeCell = chalk.red(uptimeStr.padEnd(W_UPTIME))
505
+ uptimeCell = themeColors.metricBad(uptimeStr.padEnd(W_UPTIME))
536
506
  }
537
507
 
538
508
  // 📖 Model text now mirrors the provider hue so provider affinity is visible
539
509
  // 📖 even before the eye reaches the Provider column.
540
- const nameCell = isCursor ? modelColor.bold(name) : modelColor(name)
510
+ const nameCell = themeColors.provider(r.providerKey, name, { bold: isCursor })
541
511
  const sourceCursorText = providerName.padEnd(W_SOURCE)
542
- const sourceCell = isCursor ? chalk.rgb(...providerRgb).bold(sourceCursorText) : source
512
+ const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
543
513
 
544
514
  // 📖 Usage column removed from UI – no usage data displayed.
545
515
  // (We keep the logic but do not render it.)
@@ -549,81 +519,83 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
549
519
  // 📖 exact provider/model pair, loaded from the local usage snapshot file at startup.
550
520
  const tokenTotal = Number(r.totalTokens) || 0
551
521
  const tokensCell = tokenTotal > 0
552
- ? chalk.rgb(120, 210, 255)(formatTokenTotalCompact(tokenTotal).padEnd(W_TOKENS))
553
- : chalk.dim('0'.padEnd(W_TOKENS))
522
+ ? themeColors.metricOk(formatTokenTotalCompact(tokenTotal).padEnd(W_TOKENS))
523
+ : themeColors.dim('0'.padEnd(W_TOKENS))
554
524
 
555
525
  // 📖 Build row with double space between columns (order: Rank, Tier, SWE%, CTX, Model, Provider, Latest Ping, Avg Ping, Health, Verdict, Stability, Up%, Used)
556
526
  const row = ' ' + num + COL_SEP + tier + COL_SEP + sweCell + COL_SEP + ctxCell + COL_SEP + nameCell + COL_SEP + sourceCell + COL_SEP + pingCell + COL_SEP + avgCell + COL_SEP + status + COL_SEP + speedCell + COL_SEP + stabCell + COL_SEP + uptimeCell + COL_SEP + tokensCell
557
527
 
558
528
  if (isCursor) {
559
- lines.push(chalk.bgRgb(155, 55, 135)(row))
529
+ lines.push(themeColors.bgModelCursor(row))
560
530
  } else if (r.isRecommended) {
561
531
  // 📖 Medium green background for recommended models (distinguishable from favorites)
562
- lines.push(chalk.bgRgb(15, 40, 15)(row))
532
+ lines.push(themeColors.bgModelRecommended(row))
563
533
  } else if (r.isFavorite) {
564
- lines.push(chalk.bgRgb(88, 64, 10)(row))
534
+ lines.push(themeColors.bgModelFavorite(row))
565
535
  } else {
566
536
  lines.push(row)
567
537
  }
568
538
  }
569
539
 
570
540
  if (vp.hasBelow) {
571
- lines.push(chalk.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
541
+ lines.push(themeColors.dim(` ... ${sorted.length - vp.endIdx} more below ...`))
572
542
  }
573
543
 
574
544
  lines.push('')
575
545
  // 📖 Footer hints keep only navigation and secondary actions now that the
576
546
  // 📖 active tool target is already visible in the header badge.
577
- const hotkey = (keyLabel, text) => chalk.yellow(keyLabel) + chalk.dim(text)
547
+ const hotkey = (keyLabel, text) => themeColors.hotkey(keyLabel) + themeColors.dim(text)
578
548
  // 📖 Active filter pills use a loud green background so tier/provider/configured-only
579
549
  // 📖 states are obvious even when the user misses the smaller header badges.
580
- const activeHotkey = (keyLabel, text, bg = [57, 255, 20], fg = [0, 0, 0]) => chalk.bgRgb(...bg).rgb(...fg)(` ${keyLabel}${text} `)
550
+ const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
551
+ const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
581
552
  // 📖 Line 1: core navigation + filtering shortcuts
582
553
  lines.push(
583
554
  hotkey('F', ' Toggle Favorite') +
584
- chalk.dim(` • `) +
555
+ themeColors.dim(` • `) +
585
556
  (tierFilterMode > 0
586
- ? activeHotkey('T', ` Tier (${activeTierLabel})`, ACTIVE_FILTER_BG_BY_TIER[activeTierLabel] || [57, 255, 20])
557
+ ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
587
558
  : hotkey('T', ' Tier')) +
588
- chalk.dim(` • `) +
559
+ themeColors.dim(` • `) +
589
560
  (originFilterMode > 0
590
- ? activeHotkey('D', ` Provider (${activeOriginLabel})`, [0, 0, 0], PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
561
+ ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
591
562
  : hotkey('D', ' Provider')) +
592
- chalk.dim(` • `) +
593
- (hideUnconfiguredModels ? activeHotkey('E', ' Configured Models Only') : hotkey('E', ' Configured Models Only')) +
594
- chalk.dim(` • `) +
563
+ themeColors.dim(` • `) +
564
+ (hideUnconfiguredModels ? activeHotkey('E', ' Configured Models Only', configuredBadgeBg) : hotkey('E', ' Configured Models Only')) +
565
+ themeColors.dim(` • `) +
595
566
  hotkey('P', ' Settings') +
596
- chalk.dim(` • `) +
567
+ themeColors.dim(` • `) +
597
568
  hotkey('K', ' Help')
598
569
  )
599
570
  // 📖 Line 2: install flow, recommend, feedback, and extended hints.
600
571
  lines.push(
601
- chalk.dim(` `) +
602
- hotkey('Y', ' Install endpoints') + chalk.dim(` • `) +
603
- hotkey('Q', ' Smart Recommend') + chalk.dim(` • `) +
572
+ themeColors.dim(` `) +
573
+ hotkey('Y', ' Install endpoints') + themeColors.dim(` • `) +
574
+ hotkey('Q', ' Smart Recommend') + themeColors.dim(` • `) +
575
+ hotkey('G', ' Theme') + themeColors.dim(` • `) +
604
576
  hotkey('I', ' Feedback, bugs & requests')
605
577
  )
606
578
  // 📖 Proxy status is now shown via the J badge in line 2 above — no need for a dedicated line
607
579
  const footerLine =
608
- chalk.rgb(255, 150, 200)(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
609
- chalk.dim(' • ') +
580
+ themeColors.footerLove(' Made with 💖 & ☕ by \x1b]8;;https://github.com/vava-nessa\x1b\\vava-nessa\x1b]8;;\x1b\\') +
581
+ themeColors.dim(' • ') +
610
582
  '⭐ ' +
611
- chalk.yellow('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
612
- chalk.dim(' • ') +
583
+ themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\Star on GitHub\x1b]8;;\x1b\\') +
584
+ themeColors.dim(' • ') +
613
585
  '🤝 ' +
614
- chalk.rgb(255, 165, 0)('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
615
- chalk.dim(' • ') +
586
+ themeColors.warning('\x1b]8;;https://github.com/vava-nessa/free-coding-models/graphs/contributors\x1b\\Contributors\x1b]8;;\x1b\\') +
587
+ themeColors.dim(' • ') +
616
588
  '☕ ' +
617
- chalk.rgb(255, 200, 100)('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
618
- chalk.dim(' • ') +
589
+ themeColors.footerCoffee('\x1b]8;;https://buymeacoffee.com/vavanessadev\x1b\\Buy me a coffee\x1b]8;;\x1b\\') +
590
+ themeColors.dim(' • ') +
619
591
  '💬 ' +
620
- chalk.rgb(200, 150, 255)('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
621
- chalk.dim(' → ') +
622
- chalk.rgb(200, 150, 255)('https://discord.gg/ZTNFHvvCkU') +
623
- chalk.dim(' • ') +
624
- chalk.yellow('N') + chalk.dim(' Changelog') +
625
- chalk.dim(' • ') +
626
- chalk.dim('Ctrl+C Exit')
592
+ themeColors.footerDiscord('\x1b]8;;https://discord.gg/ZTNFHvvCkU\x1b\\Discord\x1b]8;;\x1b\\') +
593
+ themeColors.dim(' → ') +
594
+ themeColors.footerDiscord('https://discord.gg/ZTNFHvvCkU') +
595
+ themeColors.dim(' • ') +
596
+ themeColors.hotkey('N') + themeColors.dim(' Changelog') +
597
+ themeColors.dim(' • ') +
598
+ themeColors.dim('Ctrl+C Exit')
627
599
  lines.push(footerLine)
628
600
 
629
601
  if (versionStatus.isOutdated) {
@@ -637,7 +609,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
637
609
 
638
610
  // 📖 Stable release notice: keep the bridge rebuild status explicit in the main UI
639
611
  // 📖 so users do not go hunting for hidden controls that are disabled on purpose.
640
- const bridgeNotice = chalk.magentaBright.italic(` ${PROXY_DISABLED_NOTICE}`)
612
+ const bridgeNotice = chalk.italic.rgb(...getTierRgb('A-'))(` ${PROXY_DISABLED_NOTICE}`)
641
613
  lines.push(bridgeNotice)
642
614
 
643
615
  // 📖 Append \x1b[K (erase to EOL) to each line so leftover chars from previous