free-coding-models 0.3.17 → 0.3.19

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