free-coding-models 0.5.95 → 0.5.96

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.
@@ -52,7 +52,8 @@ import { TIER_COLOR } from './tier-colors.js'
52
52
  import { getAvg, getVerdict, getUptime, getStabilityScore, getVersionStatusInfo, isNewModel } from '../core/utils.js'
53
53
  import { supportsUsagePercent } from '../core/quota-capabilities.js'
54
54
  import { formatBenchmarkLatency, formatBenchmarkTps } from '../core/benchmark.js'
55
- import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth, stripAnsi, fadedRow } from './render-helpers.js'
55
+ import { calculateViewport, sortResultsWithPinnedFavorites, padEndDisplay, displayWidth, stripAnsi, fadedRow, truncateAnsiWidth } from './render-helpers.js'
56
+ import { getLocale, t } from '../core/i18n/index.js'
56
57
  import { getToolMeta, TOOL_METADATA, TOOL_MODE_ORDER, isModelCompatibleWithTool } from '../core/tool-metadata.js'
57
58
  import { getColumnSpacing } from './ui-config.js'
58
59
  import { detectPackageManager, getManualInstallCmd } from '../core/updater.js'
@@ -251,10 +252,10 @@ export function renderTable({
251
252
 
252
253
  const intervalSec = Math.round(pingInterval / 1000)
253
254
  const pingModeMeta = {
254
- speed: { label: 'fast', color: themeColors.warningBold },
255
- normal: { label: 'normal', color: themeColors.accentBold },
256
- slow: { label: 'slow', color: themeColors.info },
257
- forced: { label: 'forced', color: themeColors.errorBold },
255
+ speed: { label: t('filters.pingSpeedShort'), color: themeColors.warningBold },
256
+ normal: { label: t('filters.pingNormalShort'), color: themeColors.accentBold },
257
+ slow: { label: t('filters.pingSlowShort'), color: themeColors.info },
258
+ forced: { label: t('filters.pingForcedShort'), color: themeColors.errorBold },
258
259
  }
259
260
  const activePingMode = pingModeMeta[pingMode] ?? pingModeMeta.normal
260
261
  const pingProgressText = `${completedPings}/${totalVisible}`
@@ -268,7 +269,7 @@ export function renderTable({
268
269
  const pingControlBadge =
269
270
  activePingMode.color(' [ ') +
270
271
  themeColors.hotkey('W') +
271
- activePingMode.color(` Ping Interval : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - next : `) +
272
+ activePingMode.color(` ${t('filters.pingInterval')} : ${intervalSec}s (${activePingMode.label}) - ${pingProgressText} - ${t('filters.nextPingShort')} : `) +
272
273
  nextCountdownColor(`${secondsUntilNextLabel}s`) +
273
274
  activePingMode.color(' ]')
274
275
 
@@ -278,7 +279,7 @@ export function renderTable({
278
279
  const toolMeta = getToolMeta(mode)
279
280
  const toolBadgeColor = mode === 'openclaw' ? themeColors.warningBold : themeColors.accentBold
280
281
  const toolColor = toolMeta.color ? chalk.rgb(...toolMeta.color) : toolBadgeColor
281
- const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(' Tool : ') + toolColor.bold(`${toolMeta.emoji} ${toolMeta.label}`) + toolBadgeColor(' ]')
282
+ const modeBadge = toolBadgeColor(' [ ') + themeColors.hotkey('Z') + toolBadgeColor(` ${t('filters.tool')} : `) + toolColor.bold(`${toolMeta.emoji} ${toolMeta.label}`) + toolBadgeColor(' ]')
282
283
 
283
284
  const activeHeaderBadge = (text, bg) => themeColors.badge(text, bg, getReadableTextRgb(bg))
284
285
  const versionStatus = getVersionStatusInfo(settingsUpdateState, settingsUpdateLatestVersion, startupLatestVersion, versionAlertsEnabled)
@@ -319,7 +320,7 @@ export function renderTable({
319
320
  const W_MOOD = 2
320
321
  const W_RANK = 6
321
322
  const W_TIER = 5
322
- const W_CTX = 4
323
+ const W_CTX = getLocale() === 'zh-CN' ? 6 : 4
323
324
  const W_SOURCE = 15
324
325
  // 📖 Provider names are display-capped at MAX_PROVIDER_NAME_LEN chars so an
325
326
  // 📖 over-long label can never push the following cells and desalign the
@@ -435,25 +436,29 @@ export function renderTable({
435
436
  if (showWidthWarning) {
436
437
  const lines = []
437
438
  const blankLines = Math.max(0, Math.floor(((terminalRows || 24) - 7) / 2))
438
- const warning = '🖥️ Please maximize your terminal for optimal use.'
439
- const warning2 = '⚠️ The current terminal is too small.'
440
- const warning3 = '📏 Reduce font size or maximize width of terminal.'
441
- const padLeft = Math.max(0, Math.floor((terminalCols - warning.length) / 2))
442
- const padLeft2 = Math.max(0, Math.floor((terminalCols - warning2.length) / 2))
443
- const padLeft3 = Math.max(0, Math.floor((terminalCols - warning3.length) / 2))
439
+ const warning = `🖥️ ${t('dashboard.terminalMaximize')}`
440
+ const warning2 = `⚠️ ${t('dashboard.terminalTooSmall')}`
441
+ const warning3 = `📏 ${t('dashboard.terminalReduceFont')}`
442
+ const centeredWarning = (text, color) => {
443
+ const clipped = truncateAnsiWidth(text, terminalCols, { ellipsis: '' })
444
+ const left = Math.max(0, Math.floor((terminalCols - displayWidth(clipped)) / 2))
445
+ return ' '.repeat(left) + color(clipped)
446
+ }
444
447
  for (let i = 0; i < blankLines; i++) lines.push('')
445
- lines.push(' '.repeat(padLeft) + themeColors.errorBold(warning))
448
+ lines.push(centeredWarning(warning, themeColors.errorBold))
446
449
  lines.push('')
447
- lines.push(' '.repeat(padLeft2) + themeColors.error(warning2))
450
+ lines.push(centeredWarning(warning2, themeColors.error))
448
451
  lines.push('')
449
- lines.push(' '.repeat(padLeft3) + themeColors.error(warning3))
452
+ lines.push(centeredWarning(warning3, themeColors.error))
450
453
  lines.push('')
451
- lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 34) / 2))) + themeColors.warning(`this message will hide in ${(remainingMs / 1000).toFixed(1)}s`))
454
+ const dismissCountdown = t('dashboard.warningWillHide', { seconds: (remainingMs / 1000).toFixed(1) })
455
+ lines.push(centeredWarning(dismissCountdown, themeColors.warning))
452
456
  const barTotal = Math.max(0, Math.min(terminalCols - 4, 30))
453
457
  const barFill = Math.round((elapsed / warningDurationMs) * barTotal)
454
458
  const barStr = themeColors.success('█'.repeat(barFill)) + themeColors.dim('░'.repeat(barTotal - barFill))
455
459
  lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - barTotal) / 2))) + barStr)
456
- lines.push(' '.repeat(Math.max(0, Math.floor((terminalCols - 20) / 2))) + themeColors.dim('press esc to dismiss'))
460
+ const dismissHint = t('dashboard.pressEscToDismiss')
461
+ lines.push(centeredWarning(dismissHint, themeColors.dim))
457
462
  while (terminalRows > 0 && lines.length < terminalRows) lines.push('')
458
463
  const EL = '\x1b[K'
459
464
  return lines.map(line => line + EL).join('\n')
@@ -487,26 +492,26 @@ export function renderTable({
487
492
 
488
493
  // 📖 Plain header labels — arrows are appended dynamically below.
489
494
  const moodLabel = '❔'
490
- const rankLabel = 'Rank'
491
- const tierLabel = 'Tier'
492
- const originLabel = isCompact ? 'PrOD…' : 'Provider'
493
- const modelLabel = 'Model'
494
- const sweLabel = 'SWE%'
495
- const ctxLabel = 'CTX'
496
- const pingLabel = 'Last Ping'
497
- const avgLabel = 'Avg Ping'
498
- const healthLabel = 'Health'
499
- const verdictLabel = 'Verdict'
500
- const stabLabel = isCompact ? 'StaB.' : 'Stability'
501
- const uptimeLabel = 'Up%'
495
+ const rankLabel = t('dashboard.rank')
496
+ const tierLabel = t('dashboard.tier')
497
+ const originLabel = t('dashboard.provider')
498
+ const modelLabel = t('dashboard.model')
499
+ const sweLabel = t('dashboard.swe')
500
+ const ctxLabel = t('dashboard.context')
501
+ const pingLabel = t('dashboard.lastPing')
502
+ const avgLabel = t('dashboard.avgPingShort')
503
+ const healthLabel = t('dashboard.health')
504
+ const verdictLabel = t('dashboard.verdict')
505
+ const stabLabel = t('dashboard.stability')
506
+ const uptimeLabel = t('dashboard.uptimeShort')
502
507
 
503
508
  // 📖 Helper to colorize first letter for keyboard shortcuts
504
509
  // 📖 IMPORTANT: Pad PLAIN TEXT first, then apply colors to avoid alignment issues
505
510
  const colorFirst = (text, width, colorFn = themeColors.hotkey) => {
506
- const first = text[0]
507
- const rest = text.slice(1)
511
+ const [first, ...restChars] = [...text]
512
+ const rest = restChars.join('')
508
513
  const plainText = first + rest
509
- const padding = ' '.repeat(Math.max(0, width - plainText.length))
514
+ const padding = ' '.repeat(Math.max(0, width - displayWidth(plainText)))
510
515
  return colorFn(first) + themeColors.dim(rest + padding)
511
516
  }
512
517
 
@@ -514,7 +519,7 @@ export function renderTable({
514
519
  // 📖 a vivid inverse style (bright accent bg + white bold fg) for ~250ms.
515
520
  // 📖 This gives satisfying visual feedback that the click was registered.
516
521
  const flashHeader = (plainText, width) => {
517
- const padded = plainText.length <= width ? plainText.padEnd(width) : plainText.slice(0, width)
522
+ const padded = padEndDisplay(truncateAnsiWidth(plainText, width, { ellipsis: '' }), width)
518
523
  const bg = currentPalette().accentStrong
519
524
  const fg = getReadableTextRgb(bg)
520
525
  return chalk.bold.rgb(...fg).bgRgb(...bg)(padded)
@@ -527,8 +532,8 @@ export function renderTable({
527
532
  const sortActiveHeader = (label, width) => {
528
533
  const arrow = dir
529
534
  const prefixed = arrow + ' ' + label
530
- const text = prefixed.length <= width ? prefixed : label + arrow
531
- const padded = text.padEnd(width).slice(0, width)
535
+ const text = displayWidth(prefixed) <= width ? prefixed : label + arrow
536
+ const padded = padEndDisplay(truncateAnsiWidth(text, width, { ellipsis: '' }), width)
532
537
  // 📖 Subtle dark accent background — visible but not overwhelming.
533
538
  const bg = currentPalette().cursor.defaultBg
534
539
  const fg = getReadableTextRgb(bg)
@@ -545,7 +550,7 @@ export function renderTable({
545
550
  // 📖 colKey = sort key (e.g. 'rank', 'swe'), label = plain text, width = column width.
546
551
  const headerStyle = (colKey, label, width) => {
547
552
  const arrowText = dir + ' ' + label
548
- const flashText = arrowText.length <= width ? arrowText : label + dir
553
+ const flashText = displayWidth(arrowText) <= width ? arrowText : label + dir
549
554
  if (headerFlashColumn === colKey) return flashHeader(flashText, width)
550
555
  if (sortColumn === colKey) return sortActiveHeader(label, width)
551
556
  return colorFirst(label, width)
@@ -572,30 +577,26 @@ export function renderTable({
572
577
  const verdictH_c = headerStyle('verdict', verdictLabel, W_VERDICT)
573
578
  const stabH_c = (() => {
574
579
  if (headerFlashColumn === 'stability') {
575
- const ft = (dir + ' ' + stabLabel).length <= wStab ? dir + ' ' + stabLabel : stabLabel + dir
580
+ const ft = displayWidth(dir + ' ' + stabLabel) <= wStab ? dir + ' ' + stabLabel : stabLabel + dir
576
581
  return flashHeader(ft, wStab)
577
582
  }
578
- if (sortColumn === 'stability') return sortActiveHeader(stabLabel, wStab)
579
- const plain = stabLabel
580
- const padding = ' '.repeat(Math.max(0, wStab - plain.length))
581
- return themeColors.dim('Sta') + themeColors.hotkey('B') + themeColors.dim((isCompact ? '.' : 'ility') + padding)
583
+ return headerStyle('stability', stabLabel, wStab)
582
584
  })()
583
585
  const uptimeH_c = (() => {
584
586
  if (headerFlashColumn === 'uptime') {
585
- const ft = (dir + ' ' + uptimeLabel).length <= W_UPTIME ? dir + ' ' + uptimeLabel : uptimeLabel + dir
587
+ const ft = displayWidth(dir + ' ' + uptimeLabel) <= W_UPTIME ? dir + ' ' + uptimeLabel : uptimeLabel + dir
586
588
  return flashHeader(ft, W_UPTIME)
587
589
  }
588
- if (sortColumn === 'uptime') return sortActiveHeader(uptimeLabel, W_UPTIME)
589
- const padding = ' '.repeat(Math.max(0, W_UPTIME - uptimeLabel.length))
590
- return themeColors.hotkey('U') + themeColors.dim('p%' + padding)
590
+ return headerStyle('uptime', uptimeLabel, W_UPTIME)
591
591
  })()
592
592
  const originH_c = (() => {
593
593
  if (headerFlashColumn === 'origin') {
594
- const ft = (dir + ' ' + originLabel).length <= wSource ? dir + ' ' + originLabel : originLabel + dir
594
+ const ft = displayWidth(dir + ' ' + originLabel) <= wSource ? dir + ' ' + originLabel : originLabel + dir
595
595
  return flashHeader(ft, wSource)
596
596
  }
597
597
  if (sortColumn === 'origin') return sortActiveHeader(originLabel, wSource)
598
- if (originFilterMode > 0) return themeColors.accentBold(originLabel.padEnd(wSource))
598
+ if (originFilterMode > 0) return themeColors.accentBold(padEndDisplay(originLabel, wSource))
599
+ if (getLocale() === 'zh-CN') return headerStyle('origin', originLabel, wSource)
599
600
  const plain = isCompact ? 'PrOD…' : 'PrOviDer'
600
601
  const padding = ' '.repeat(Math.max(0, wSource - plain.length))
601
602
  if (isCompact) {
@@ -605,16 +606,15 @@ export function renderTable({
605
606
  })()
606
607
 
607
608
  // 📖 Benchmark headers — split the old combined AI Speed field into latency + throughput.
608
- const aiLatencyLabel = isCompact ? 'AI Lat.' : 'AI Latency'
609
+ const aiLatencyLabel = isCompact && getLocale() === 'en' ? 'AI Lat.' : t('dashboard.aiLatency')
609
610
  const aiLatencyH_c = (() => {
610
611
  if (headerFlashColumn === 'aiLatency') {
611
- const ft = (dir + ' ' + aiLatencyLabel).length <= wAiLatency ? dir + ' ' + aiLatencyLabel : aiLatencyLabel + dir
612
+ const ft = displayWidth(dir + ' ' + aiLatencyLabel) <= wAiLatency ? dir + ' ' + aiLatencyLabel : aiLatencyLabel + dir
612
613
  return flashHeader(ft, wAiLatency)
613
614
  }
614
615
  if (sortColumn === 'aiLatency') return sortActiveHeader(aiLatencyLabel, wAiLatency)
615
616
  const plain = aiLatencyLabel
616
- const padding = ' '.repeat(Math.max(0, wAiLatency - plain.length))
617
- return themeColors.dim(plain + padding)
617
+ return themeColors.dim(padEndDisplay(plain, wAiLatency))
618
618
  })()
619
619
  const tpsH_c = (() => {
620
620
  if (headerFlashColumn === 'tps') {
@@ -632,7 +632,7 @@ export function renderTable({
632
632
  return flashHeader(ft, W_QUOTA)
633
633
  }
634
634
  if (sortColumn === 'usage') return sortActiveHeader('Quota', W_QUOTA)
635
- return themeColors.dim('Quota'.padEnd(W_QUOTA))
635
+ return themeColors.dim(padEndDisplay(t('dashboard.quota'), W_QUOTA))
636
636
  })()
637
637
 
638
638
  // 📖 Header row: conditionally include columns based on responsive visibility
@@ -655,10 +655,10 @@ export function renderTable({
655
655
  if (sorted.length === 0) {
656
656
  lines.push('')
657
657
  if (hideUnconfiguredModels) {
658
- lines.push(` ${themeColors.errorBold('Press P to configure your API key.')}`)
659
- lines.push(` ${themeColors.dim('No configured provider currently exposes visible models in the table.')}`)
658
+ lines.push(` ${themeColors.errorBold(t('dashboard.configureApiKeyHint'))}`)
659
+ lines.push(` ${themeColors.dim(t('dashboard.noConfiguredVisibleModels'))}`)
660
660
  } else {
661
- lines.push(` ${themeColors.warningBold('No models match the current filters.')}`)
661
+ lines.push(` ${themeColors.warningBold(t('dashboard.noModels'))}`)
662
662
  }
663
663
  }
664
664
 
@@ -746,8 +746,8 @@ export function renderTable({
746
746
  const isCursor = cursor !== null && i === cursor
747
747
 
748
748
  // 📖 Left-aligned columns - pad plain text first, then colorize
749
- const num = themeColors.dim(String(r.idx).padEnd(W_RANK))
750
- const tier = tierFn(r.tier.padEnd(W_TIER))
749
+ const num = themeColors.dim(padEndDisplay(String(r.idx), W_RANK))
750
+ const tier = tierFn(padEndDisplay(r.tier, W_TIER))
751
751
  // 📖 Keep terminal view provider-specific so each row is monitorable per provider
752
752
  // 📖 In compact mode, truncate provider name to 4 chars + '…'
753
753
  // 📖 In normal mode, cap names at MAX_PROVIDER_NAME_LEN chars (13 + '…')
@@ -759,7 +759,7 @@ export function renderTable({
759
759
  : providerName.length > MAX_PROVIDER_NAME_LEN
760
760
  ? providerName.slice(0, MAX_PROVIDER_NAME_LEN - 1).trimEnd() + '…'
761
761
  : providerName
762
- const source = themeColors.provider(r.providerKey, providerDisplay.padEnd(wSource))
762
+ const source = themeColors.provider(r.providerKey, padEndDisplay(providerDisplay, wSource))
763
763
  // 📖 Prefix: ⭐ favorite > 🎯 recommended > 🆕 new — only one emoji, never shifts the line
764
764
  const modelIsNew = isNewModel(r.addedDate)
765
765
  let favoritePrefix = ''
@@ -782,10 +782,10 @@ export function renderTable({
782
782
  // ≥20% red (B), <20% dark red (C), '—' dim
783
783
  let sweCell
784
784
  if (sweScore === '—') {
785
- sweCell = themeColors.dim(sweScore.padEnd(W_SWE))
785
+ sweCell = themeColors.dim(padEndDisplay(sweScore, W_SWE))
786
786
  } else {
787
787
  const sweVal = parseFloat(sweScore)
788
- const swePadded = sweScore.padEnd(W_SWE)
788
+ const swePadded = padEndDisplay(sweScore, W_SWE)
789
789
  sweCell = paintSweScore(sweVal, swePadded)
790
790
  }
791
791
 
@@ -793,31 +793,31 @@ export function renderTable({
793
793
  const ctxRaw = r.ctx ?? '—'
794
794
  let ctxCell
795
795
  if (ctxRaw === '—') {
796
- ctxCell = themeColors.dim(ctxRaw.padEnd(W_CTX))
796
+ ctxCell = themeColors.dim(padEndDisplay(ctxRaw, W_CTX))
797
797
  } else {
798
798
  const ctxMatch = ctxRaw.match(/^(\d+)k$|^(\d+)M$/)
799
799
  if (ctxMatch) {
800
800
  const numK = ctxMatch[1] ? parseInt(ctxMatch[1]) : parseInt(ctxMatch[2]) * 1024
801
801
  ctxCell = numK <= 32
802
- ? themeColors.metricBad(ctxRaw.padEnd(W_CTX))
802
+ ? themeColors.metricBad(padEndDisplay(ctxRaw, W_CTX))
803
803
  : numK <= 64
804
- ? themeColors.metricWarn(ctxRaw.padEnd(W_CTX))
804
+ ? themeColors.metricWarn(padEndDisplay(ctxRaw, W_CTX))
805
805
  : numK <= 128
806
- ? chalk.rgb(...currentPalette().ctxGold).bold(ctxRaw.padEnd(W_CTX))
806
+ ? chalk.rgb(...currentPalette().ctxGold).bold(padEndDisplay(ctxRaw, W_CTX))
807
807
  : numK <= 256
808
- ? chalk.rgb(...currentPalette().ctxGreen).bold(ctxRaw.padEnd(W_CTX))
808
+ ? chalk.rgb(...currentPalette().ctxGreen).bold(padEndDisplay(ctxRaw, W_CTX))
809
809
  : numK <= 400
810
- ? chalk.rgb(...currentPalette().ctxTeal).bold(ctxRaw.padEnd(W_CTX))
811
- : chalk.rgb(...currentPalette().ctxCyan).bold.underline(ctxRaw.padEnd(W_CTX))
810
+ ? chalk.rgb(...currentPalette().ctxTeal).bold(padEndDisplay(ctxRaw, W_CTX))
811
+ : chalk.rgb(...currentPalette().ctxCyan).bold.underline(padEndDisplay(ctxRaw, W_CTX))
812
812
  } else {
813
- ctxCell = themeColors.dim(ctxRaw.padEnd(W_CTX))
813
+ ctxCell = themeColors.dim(padEndDisplay(ctxRaw, W_CTX))
814
814
  }
815
815
  }
816
816
 
817
817
  // 📖 Keep the row-local spinner small and inline so users can still read the last measured latency.
818
818
  const buildLatestPingDisplay = (value) => {
819
819
  const spinner = r.isPinging ? ` ${FRAMES[frame % FRAMES.length]}` : ''
820
- return `${value}${spinner}`.padEnd(wPing)
820
+ return padEndDisplay(`${value}${spinner}`, wPing)
821
821
  }
822
822
 
823
823
  // 📖 Latest ping - pings are objects: { ms, code }
@@ -825,7 +825,7 @@ export function renderTable({
825
825
  const latestPing = r.pings.length > 0 ? r.pings[r.pings.length - 1] : null
826
826
  let pingCell
827
827
  if (!latestPing) {
828
- const placeholder = r.isPinging ? buildLatestPingDisplay('———') : '———'.padEnd(wPing)
828
+ const placeholder = r.isPinging ? buildLatestPingDisplay('———') : padEndDisplay('———', wPing)
829
829
  pingCell = themeColors.dim(placeholder)
830
830
  } else if (latestPing.code === '200') {
831
831
  // 📖 Success - show response time
@@ -836,7 +836,7 @@ export function renderTable({
836
836
  pingCell = themeColors.dim(buildLatestPingDisplay(String(latestPing.ms)))
837
837
  } else {
838
838
  // 📖 Error or timeout - show "———" (error code is already in Status column)
839
- const placeholder = r.isPinging ? buildLatestPingDisplay('———') : '———'.padEnd(wPing)
839
+ const placeholder = r.isPinging ? buildLatestPingDisplay('———') : padEndDisplay('———', wPing)
840
840
  pingCell = themeColors.dim(placeholder)
841
841
  }
842
842
 
@@ -844,10 +844,10 @@ export function renderTable({
844
844
  const avg = getAvg(r)
845
845
  let avgCell
846
846
  if (avg !== Infinity) {
847
- const str = String(avg).padEnd(wAvg)
847
+ const str = padEndDisplay(String(avg), wAvg)
848
848
  avgCell = avg < 500 ? themeColors.metricGood(str) : avg < 1500 ? themeColors.metricWarn(str) : themeColors.metricBad(str)
849
849
  } else {
850
- avgCell = themeColors.dim('———'.padEnd(wAvg))
850
+ avgCell = themeColors.dim(padEndDisplay('———', wAvg))
851
851
  }
852
852
 
853
853
  // 📖 Status column - build plain text with emoji, pad, then colorize
@@ -855,21 +855,21 @@ export function renderTable({
855
855
  let statusText, statusColor
856
856
  if (r.status === 'noauth') {
857
857
  // 📖 Server responded but needs an API key — shown dimly since it IS reachable
858
- statusText = `🔑 NO KEY`
858
+ statusText = `🔑 ${t('dashboard.noKeyShort')}`
859
859
  statusColor = themeColors.dim
860
860
  } else if (r.status === 'auth_error') {
861
861
  // 📖 A key is configured but the provider rejected it — keep this distinct
862
862
  // 📖 from "no key" so configured-only mode does not look misleading.
863
- statusText = `🔐 AUTH FAIL`
863
+ statusText = `🔐 ${t('dashboard.authFailShort')}`
864
864
  statusColor = themeColors.errorBold
865
865
  } else if (r.status === 'pending') {
866
- statusText = `${FRAMES[frame % FRAMES.length]} wait`
866
+ statusText = `${FRAMES[frame % FRAMES.length]} ${t('common.wait')}`
867
867
  statusColor = themeColors.warning
868
868
  } else if (r.status === 'up') {
869
- statusText = `✅ UP`
869
+ statusText = `✅ ${t('dashboard.upShort')}`
870
870
  statusColor = themeColors.success
871
871
  } else if (r.status === 'timeout') {
872
- statusText = `⏳ TIMEOUT`
872
+ statusText = `⏳ ${t('filters.health.timeout').toUpperCase()}`
873
873
  statusColor = themeColors.warning
874
874
  } else if (r.status === 'down') {
875
875
  const code = r.httpCode ?? 'ERR'
@@ -899,10 +899,10 @@ export function renderTable({
899
899
  const statusDisplayText = isCompact ? (() => {
900
900
  // 📖 Strip emoji prefix to measure text length, then truncate if needed
901
901
  const plainText = statusText.replace(/^[\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*/u, '')
902
- if (plainText.length > 6) {
902
+ if (displayWidth(plainText) > 6) {
903
903
  const emojiMatch = statusText.match(/^([\p{Emoji_Presentation}\p{Extended_Pictographic}]\s*)/u)
904
904
  const prefix = emojiMatch ? emojiMatch[1] : ''
905
- return prefix + plainText.slice(0, 6) + '…'
905
+ return prefix + truncateAnsiWidth(plainText, 6, { ellipsis: '…' })
906
906
  }
907
907
  return statusText
908
908
  })() : statusText
@@ -915,53 +915,53 @@ export function renderTable({
915
915
  switch (verdict) {
916
916
  case 'Perfect':
917
917
  verdictIcon = '🟩'
918
- verdictText = `${verdictIcon} Perfect`
918
+ verdictText = `${verdictIcon} ${t('filters.verdict.perfect')}`
919
919
  verdictColor = themeColors.successBold
920
920
  break
921
921
  case 'Normal':
922
922
  verdictIcon = '🟢'
923
- verdictText = `${verdictIcon} Normal`
923
+ verdictText = `${verdictIcon} ${t('filters.verdict.normal')}`
924
924
  verdictColor = themeColors.metricGood
925
925
  break
926
926
  case 'Spiky':
927
927
  verdictIcon = '🟡'
928
- verdictText = `${verdictIcon} Spiky`
928
+ verdictText = `${verdictIcon} ${t('filters.verdict.spiky')}`
929
929
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A+'))(text)
930
930
  break
931
931
  case 'Slow':
932
932
  verdictIcon = '🟠'
933
- verdictText = `${verdictIcon} Slow`
933
+ verdictText = `${verdictIcon} ${t('filters.verdict.slow')}`
934
934
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('A-'))(text)
935
935
  break
936
936
  case 'Very Slow':
937
937
  verdictIcon = '🔴'
938
- verdictText = `${verdictIcon} Very Slow`
938
+ verdictText = `${verdictIcon} ${t('filters.verdict.verySlow')}`
939
939
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B+'))(text)
940
940
  break
941
941
  case 'Overloaded':
942
942
  verdictIcon = '🔥'
943
- verdictText = `${verdictIcon} Overloaded`
943
+ verdictText = `${verdictIcon} ${t('filters.verdict.overloaded')}`
944
944
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('B'))(text)
945
945
  break
946
946
  case 'Unstable':
947
947
  // 📖 Avoid ⚠️ here: its variation selector has inconsistent terminal width and shifts the tiny ❔ column.
948
948
  verdictIcon = '🟥'
949
- verdictText = `${verdictIcon} Unstable`
949
+ verdictText = `${verdictIcon} ${t('filters.verdict.unstable')}`
950
950
  verdictColor = themeColors.errorBold
951
951
  break
952
952
  case 'Not Active':
953
953
  verdictIcon = '⚫'
954
- verdictText = `${verdictIcon} Not Active`
954
+ verdictText = `${verdictIcon} ${t('filters.verdict.notActive')}`
955
955
  verdictColor = themeColors.dim
956
956
  break
957
957
  case 'Pending':
958
958
  verdictIcon = '⏳'
959
- verdictText = `${verdictIcon} Pending`
959
+ verdictText = `${verdictIcon} ${t('dashboard.pending')}`
960
960
  verdictColor = themeColors.dim
961
961
  break
962
962
  default:
963
963
  verdictIcon = '💀'
964
- verdictText = `${verdictIcon} Unusable`
964
+ verdictText = `${verdictIcon} ${t('filters.verdict.unusable')}`
965
965
  verdictColor = (text) => chalk.bold.rgb(...getTierRgb('C'))(text)
966
966
  break
967
967
  }
@@ -973,15 +973,15 @@ export function renderTable({
973
973
  const stabScore = getStabilityScore(r)
974
974
  let stabCell
975
975
  if (stabScore < 0) {
976
- stabCell = themeColors.dim('———'.padEnd(wStab))
976
+ stabCell = themeColors.dim(padEndDisplay('———', wStab))
977
977
  } else if (stabScore >= 80) {
978
- stabCell = themeColors.metricGood(String(stabScore).padEnd(wStab))
978
+ stabCell = themeColors.metricGood(padEndDisplay(String(stabScore), wStab))
979
979
  } else if (stabScore >= 60) {
980
- stabCell = themeColors.metricOk(String(stabScore).padEnd(wStab))
980
+ stabCell = themeColors.metricOk(padEndDisplay(String(stabScore), wStab))
981
981
  } else if (stabScore >= 40) {
982
- stabCell = themeColors.metricWarn(String(stabScore).padEnd(wStab))
982
+ stabCell = themeColors.metricWarn(padEndDisplay(String(stabScore), wStab))
983
983
  } else {
984
- stabCell = themeColors.metricBad(String(stabScore).padEnd(wStab))
984
+ stabCell = themeColors.metricBad(padEndDisplay(String(stabScore), wStab))
985
985
  }
986
986
 
987
987
  // 📖 Uptime column - percentage of successful pings
@@ -990,19 +990,19 @@ export function renderTable({
990
990
  const uptimeStr = uptimePercent + '%'
991
991
  let uptimeCell
992
992
  if (uptimePercent >= 90) {
993
- uptimeCell = themeColors.metricGood(uptimeStr.padEnd(W_UPTIME))
993
+ uptimeCell = themeColors.metricGood(padEndDisplay(uptimeStr, W_UPTIME))
994
994
  } else if (uptimePercent >= 70) {
995
- uptimeCell = themeColors.metricWarn(uptimeStr.padEnd(W_UPTIME))
995
+ uptimeCell = themeColors.metricWarn(padEndDisplay(uptimeStr, W_UPTIME))
996
996
  } else if (uptimePercent >= 50) {
997
- uptimeCell = chalk.rgb(...getTierRgb('A-'))(uptimeStr.padEnd(W_UPTIME))
997
+ uptimeCell = chalk.rgb(...getTierRgb('A-'))(padEndDisplay(uptimeStr, W_UPTIME))
998
998
  } else {
999
- uptimeCell = themeColors.metricBad(uptimeStr.padEnd(W_UPTIME))
999
+ uptimeCell = themeColors.metricBad(padEndDisplay(uptimeStr, W_UPTIME))
1000
1000
  }
1001
1001
 
1002
1002
  // 📖 Model text now mirrors the provider hue so provider affinity is visible
1003
1003
  // 📖 even before the eye reaches the Provider column.
1004
1004
  const nameCell = themeColors.provider(r.providerKey, name, { bold: isCursor })
1005
- const sourceCursorText = providerDisplay.padEnd(wSource)
1005
+ const sourceCursorText = padEndDisplay(providerDisplay, wSource)
1006
1006
  const sourceCell = isCursor ? themeColors.provider(r.providerKey, sourceCursorText, { bold: true }) : source
1007
1007
 
1008
1008
  // 📖 Check if this model is incompatible with the active tool mode
@@ -1165,7 +1165,7 @@ export function renderTable({
1165
1165
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
1166
1166
 
1167
1167
  const configuredFilterActive = hideUnconfiguredModels || bestModeOnly
1168
- const configuredFilterText = bestModeOnly ? 'Usable only' : (hideUnconfiguredModels ? 'Configured only' : 'Active only')
1168
+ const configuredFilterText = bestModeOnly ? t('dashboard.workingOnly') : (hideUnconfiguredModels ? t('filters.visibility.configured') : t('filters.visibility.all'))
1169
1169
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
1170
1170
  const activeFilterHotkey = (keyLabel, text, bg) => themeColors.hotkey(keyLabel) + themeColors.badge(text, bg, getReadableTextRgb(bg))
1171
1171
 
@@ -1179,23 +1179,23 @@ export function renderTable({
1179
1179
  {
1180
1180
  const parts = [
1181
1181
  { text: ' ', key: null },
1182
- { text: 'F Favorite', key: 'f' },
1182
+ { text: `F ${t('dashboard.favorite')}`, key: 'f' },
1183
1183
  { text: ' • ', key: null },
1184
- { text: 'Y Fav Mode', key: 'y' },
1184
+ { text: `Y ${t('dashboard.favorites')}`, key: 'y' },
1185
1185
  { text: ' • ', key: null },
1186
- { text: tierFilterMode > 0 ? `T Tier (${activeTierLabel})` : 'T Tier', key: 't' },
1186
+ { text: tierFilterMode > 0 ? `T ${t('dashboard.tier')} (${activeTierLabel})` : `T ${t('dashboard.tier')}`, key: 't' },
1187
1187
  { text: ' • ', key: null },
1188
- { text: originFilterMode > 0 ? `D Provider (${activeOriginLabel})` : 'D Provider', key: 'd' },
1188
+ { text: originFilterMode > 0 ? `D ${t('dashboard.provider')} (${activeOriginLabel})` : `D ${t('dashboard.provider')}`, key: 'd' },
1189
1189
  { text: ' • ', key: null },
1190
1190
  { text: `E ${configuredFilterText}`, key: 'e' },
1191
1191
  { text: ' • ', key: null },
1192
- { text: 'P Settings', key: 'p' },
1192
+ { text: `P ${t('settings.title')}`, key: 'p' },
1193
1193
  { text: ' • ', key: null },
1194
- { text: 'I Help', key: 'i' },
1194
+ { text: `I ${t('help.title')}`, key: 'i' },
1195
1195
  { text: ' • ', key: null },
1196
- { text: 'N Reset', key: 'n' },
1196
+ { text: `N ${t('common.reset')}`, key: 'n' },
1197
1197
  { text: ' • ', key: null },
1198
- { text: 'G Theme', key: 'g' },
1198
+ { text: `G ${t('settings.theme')}`, key: 'g' },
1199
1199
  ]
1200
1200
  const footerRow1 = lines.length + 1 // 📖 1-based terminal row (line hasn't been pushed yet)
1201
1201
  let xPos = 1
@@ -1207,34 +1207,34 @@ export function renderTable({
1207
1207
  }
1208
1208
 
1209
1209
  lines.push(
1210
- ' ' + hotkey('F', ' Favorite') +
1210
+ ' ' + hotkey('F', ` ${t('dashboard.favorite')}`) +
1211
1211
  themeColors.dim(` • `) +
1212
- hotkey('Y', ' Fav Mode') +
1212
+ hotkey('Y', ` ${t('dashboard.favorites')}`) +
1213
1213
  themeColors.dim(` • `) +
1214
1214
  (tierFilterMode > 0
1215
1215
  ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
1216
- : hotkey('T', ' Tier')) +
1216
+ : hotkey('T', ` ${t('dashboard.tier')}`)) +
1217
1217
  themeColors.dim(` • `) +
1218
1218
  (originFilterMode > 0
1219
1219
  ? activeHotkey('D', ` Provider (${activeOriginLabel})`, PROVIDER_COLOR[[null, ...Object.keys(sources)][originFilterMode]] || [255, 255, 255])
1220
- : hotkey('D', ' Provider')) +
1220
+ : hotkey('D', ` ${t('dashboard.provider')}`)) +
1221
1221
  themeColors.dim(` • `) +
1222
1222
  (configuredFilterActive
1223
1223
  ? activeFilterHotkey('E', configuredFilterText, configuredBadgeBg)
1224
- : hotkey('E', ' Active only')) +
1224
+ : hotkey('E', ` ${t('filters.visibility.all')}`)) +
1225
1225
  themeColors.dim(` • `) +
1226
- hotkey('P', ' Settings') +
1226
+ hotkey('P', ` ${t('settings.title')}`) +
1227
1227
  themeColors.dim(` • `) +
1228
- hotkey('I', ' Help') +
1228
+ hotkey('I', ` ${t('help.title')}`) +
1229
1229
  themeColors.dim(` • `) +
1230
- hotkey('N', ' Reset') +
1230
+ hotkey('N', ` ${t('common.reset')}`) +
1231
1231
  themeColors.dim(` • `) +
1232
- themeColors.hotkey('G') + themeColors.infoBold(' Theme')
1232
+ themeColors.hotkey('G') + themeColors.infoBold(` ${t('settings.theme')}`)
1233
1233
  )
1234
1234
 
1235
1235
  // 📖 Line 2: command palette + GitHub
1236
1236
  {
1237
- const cpText = ' Ctrl+P Cmd Palette '
1237
+ const cpText = ` Ctrl+P ${t('palette.footerTitle')} `
1238
1238
  const parts = [
1239
1239
  { text: ' ', key: null },
1240
1240
  { text: cpText, key: 'ctrl+p' },
@@ -1250,7 +1250,7 @@ export function renderTable({
1250
1250
  }
1251
1251
 
1252
1252
  // 📖 Line 2: command palette (simple color, no background) + GitHub link.
1253
- const paletteLabel = chalk.rgb(...currentPalette().cmdPalette).bold('Ctrl+P Cmd Palette')
1253
+ const paletteLabel = chalk.rgb(...currentPalette().cmdPalette).bold(`Ctrl+P ${t('palette.footerTitle')}`)
1254
1254
  const starLink = '⭐ ' + themeColors.link('\x1b]8;;https://github.com/vava-nessa/free-coding-models\x1b\\GitHub\x1b]8;;\x1b\\')
1255
1255
  lines.push(
1256
1256
  ' ' + paletteLabel + themeColors.dim(` • `) + starLink + themeColors.dim(` • `) +
@@ -1258,9 +1258,14 @@ export function renderTable({
1258
1258
  )
1259
1259
 
1260
1260
  if (versionStatus.isOutdated) {
1261
+ const retryHint = t('update.retryHint')
1262
+ const retryTail = ` • ${retryHint} `
1263
+ const warningText = updateWarningMessage && terminalCols > 0
1264
+ ? truncateAnsiWidth(updateWarningMessage, Math.max(1, terminalCols - displayWidth(` ${retryTail}`)), { ellipsis: '…' })
1265
+ : updateWarningMessage
1261
1266
  const updateMsg = updateWarningMessage
1262
- ? ` ${updateWarningMessage} • Press Shift+U to retry update `
1263
- : ` 🚀⬆️ UPDATE AVAILABLE — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • Click here or press Shift+U to update 🚀⬆️ `
1267
+ ? ` ${warningText}${retryTail}`
1268
+ : ` 🚀⬆️ ${t('update.available').toUpperCase()} — v${LOCAL_VERSION} → v${versionStatus.latestVersion} • ${t('update.clickToInstall')} 🚀⬆️ `
1264
1269
  const paddedBanner = terminalCols > 0
1265
1270
  ? updateMsg + ' '.repeat(Math.max(0, terminalCols - displayWidth(updateMsg)))
1266
1271
  : updateMsg
@@ -1281,14 +1286,14 @@ export function renderTable({
1281
1286
  let filterBadge = ''
1282
1287
  if (hasCustomFilter) {
1283
1288
  const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
1284
- const filterPrefix = 'X Disable filter: "'
1289
+ const filterPrefix = `X ${t('filters.disableFilter')}: "`
1285
1290
  const filterSuffix = '"'
1286
1291
  const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
1287
1292
  const availableFilterWidth = terminalCols > 0
1288
1293
  ? Math.max(8, terminalCols - 4 - baseBadgeWidth)
1289
- : normalizedFilter.length
1290
- const visibleFilter = normalizedFilter.length > availableFilterWidth
1291
- ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
1294
+ : displayWidth(normalizedFilter)
1295
+ const visibleFilter = displayWidth(normalizedFilter) > availableFilterWidth
1296
+ ? truncateAnsiWidth(normalizedFilter, availableFilterWidth, { ellipsis: '…' })
1292
1297
  : normalizedFilter
1293
1298
  filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
1294
1299
  }
@@ -1296,7 +1301,7 @@ export function renderTable({
1296
1301
  if (hasCustomFilter) {
1297
1302
  // 📖 Mouse support: register click zone for the X-clear filter badge
1298
1303
  const lastFooterRow = lines.length + 1
1299
- const badgePlain = `X Disable filter: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
1304
+ const badgePlain = `X ${t('filters.disableFilter')}: "${customTextFilter.trim().replace(/\s+/g, ' ')}"`
1300
1305
  const fullText = ' ' + ` ${badgePlain} `
1301
1306
  const xStart = 3 // 📖 after the leading 2 spaces
1302
1307
  const xEnd = xStart + displayWidth(` ${badgePlain} `) - 1
@@ -1308,8 +1313,8 @@ export function renderTable({
1308
1313
  const releaseLabel = lastReleaseDate
1309
1314
  ? chalk.rgb(...currentPalette().releaseDate)(`Last release: ${lastReleaseDate}`)
1310
1315
  : ''
1311
- const speedTestLabel = chalk.bgRgb(...currentPalette().badgeSpeedTestBg).rgb(...currentPalette().badgeSpeedTestFg).bold(' NEW ⭐️ Ctrl+A 🤖 AI Speed Test ')
1312
- const globalBenchmarkLabel = chalk.bgRgb(...currentPalette().badgeBenchmarkBg).rgb(...currentPalette().badgeBenchmarkFg).bold(' NEW Ctrl+U : Global AI Speed Test (Uses a lot of requests!) ')
1316
+ const speedTestLabel = chalk.bgRgb(...currentPalette().badgeSpeedTestBg).rgb(...currentPalette().badgeSpeedTestFg).bold(` ⭐️ Ctrl+A 🤖 ${t('dashboard.aiSpeedTest')} `)
1317
+ const globalBenchmarkLabel = chalk.bgRgb(...currentPalette().badgeBenchmarkBg).rgb(...currentPalette().badgeBenchmarkFg).bold(` Ctrl+U : ${t('dashboard.globalBenchmark')} `)
1313
1318
 
1314
1319
  // 📖 Probe badge: show progress when 404 probe is running or recently completed.
1315
1320
  // 📖 Bar width is clamped to 0-20 cells (issue #168 kick-out fix): a transient
@@ -1465,5 +1470,5 @@ export function renderTable({
1465
1470
  // 📖 Every line is prefixed with the theme bg so content always renders on
1466
1471
  // 📖 the correct background. \x1b[K fills to end-of-line. The app-level render
1467
1472
  // 📖 loop applies patchThemeBg() to undo chalk's \x1b[49m resets globally.
1468
- return cleared.join('\n')
1473
+ return cleared.map(line => terminalCols > 0 ? truncateAnsiWidth(line, terminalCols, { ellipsis: '' }) + '\x1b[K' : line).join('\n')
1469
1474
  }