free-coding-models 0.5.95 → 0.5.97

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.
@@ -49,6 +49,7 @@ import { discoverRouterV2Port, testModelViaRouter, testSetViaRouter } from './be
49
49
  // 📖 After the merge, the v2 engine lives in the MAIN router daemon: all
50
50
  // discovery and lifecycle calls target the historical daemon paths/ports.
51
51
  import { getRouterPidPath, getRouterPortPath, getRouterPortRange } from '../router-daemon.js'
52
+ import { t } from '../i18n/index.js'
52
53
 
53
54
  export const ROUTER_V2_DASHBOARD_POLL_INTERVAL_MS = 2000
54
55
  export const ROUTER_V2_DASHBOARD_FETCH_TIMEOUT_MS = 1500
@@ -429,15 +430,15 @@ function formatDurationV2(seconds) {
429
430
 
430
431
  function stateBadgeV2(modelState) {
431
432
  switch (modelState) {
432
- case 'CLOSED': return { text: '✅ UP', color: themeColors.success }
433
- case 'DEGRADED': return { text: '🟠 DEGRADED', color: themeColors.warningBold }
434
- case 'HALF_OPEN': return { text: '🔁 PROBING', color: themeColors.warning }
435
- case 'OPEN': return { text: '⛔ OPEN', color: themeColors.error }
436
- case 'AUTH_ERROR': return { text: '🔐 AUTH FAIL', color: themeColors.errorBold }
437
- case 'QUOTA_PAUSED': return { text: '🔥 QUOTA', color: themeColors.warningBold }
438
- case 'STALE': return { text: '👻 STALE', color: themeColors.dim }
439
- case 'UNSUPPORTED': return { text: '🚫 UNSUPPORTED', color: themeColors.dim }
440
- default: return { text: '⏳ PENDING', color: themeColors.dim }
433
+ case 'CLOSED': return { text: `✅ ${t('dashboard.working').toUpperCase()}`, color: themeColors.success }
434
+ case 'DEGRADED': return { text: `🟠 ${t('router.circuit.degraded').toUpperCase()}`, color: themeColors.warningBold }
435
+ case 'HALF_OPEN': return { text: `🔁 ${t('router.circuit.probing').toUpperCase()}`, color: themeColors.warning }
436
+ case 'OPEN': return { text: `⛔ ${t('router.circuit.down').toUpperCase()}`, color: themeColors.error }
437
+ case 'AUTH_ERROR': return { text: `🔐 ${t('router.circuit.authError').toUpperCase()}`, color: themeColors.errorBold }
438
+ case 'QUOTA_PAUSED': return { text: `🔥 ${t('router.circuit.quotaPaused').toUpperCase()}`, color: themeColors.warningBold }
439
+ case 'STALE': return { text: `👻 ${t('router.circuit.deprecated').toUpperCase()}`, color: themeColors.dim }
440
+ case 'UNSUPPORTED': return { text: `🚫 ${t('router.circuit.unsupported').toUpperCase()}`, color: themeColors.dim }
441
+ default: return { text: `⏳ ${t('dashboard.pending').toUpperCase()}`, color: themeColors.dim }
441
442
  }
442
443
  }
443
444
 
@@ -489,17 +490,17 @@ export function renderRouterV2Dashboard(state, deps = {}) {
489
490
  // ── Quick Setup ─────────────────────────────────────────────────────────────
490
491
  const { defaultPort } = getRouterPortRange()
491
492
  const port = state.routerV2Port || defaultPort
492
- lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('(beta)')} ${themeColors.dim('- point your coding tool at v2')}`)
493
+ lines.push(` ${themeColors.textBold(t('router.quickSetup'))} ${themeColors.dim(`(${t('router.v2.beta')})`)} ${themeColors.dim(`— ${t('router.v2.pointToolAtV2')}`)}`)
493
494
  lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(`http://localhost:${port}/v1`)} ${themeColors.dim('Anthropic:')} ${themeColors.infoBold(`http://localhost:${port}`)} ${themeColors.dim('(POST /v1/messages)')}`)
494
- lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')} ${themeColors.dim('or pin one:')} ${themeColors.infoBold('fcm:@provider/model')}`)
495
+ lines.push(` ${themeColors.dim(t('router.model'))} ${themeColors.infoBold('fcm')} ${themeColors.dim(t('router.v2.orPinOne'))} ${themeColors.infoBold('fcm:@provider/model')}`)
495
496
  lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
496
497
  if (isRunning) {
497
- lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatDurationV2(toFiniteNumber(stats?.uptimeSeconds, 0)))} ${themeColors.dim('Routed:')} ${themeColors.info(String(toFiniteNumber(stats?.requestsRouted, 0)))} ${themeColors.dim('Failover rate:')} ${themeColors.info(`${Math.round(toFiniteNumber(stats?.history?.failover_rate, 0) * 100)}%`)}`)
498
+ lines.push(` ${themeColors.dim(t('router.uptime'))} ${themeColors.success(formatDurationV2(toFiniteNumber(stats?.uptimeSeconds, 0)))} ${themeColors.dim(`${t('router.requests')}:`)} ${themeColors.info(String(toFiniteNumber(stats?.requestsRouted, 0)))} ${themeColors.dim(`${t('router.v2.failoverRate')}:`)} ${themeColors.info(`${Math.round(toFiniteNumber(stats?.history?.failover_rate, 0) * 100)}%`)}`)
498
499
  }
499
500
  lines.push(` ${separator}`)
500
501
 
501
502
  // ── Fallback chain with live breaker states ─────────────────────────────────
502
- lines.push(` ${themeColors.textBold('Fallback Chain')} ${themeColors.dim('- routing order for the next request')}`)
503
+ lines.push(` ${themeColors.textBold(t('router.v2.fallbackChain'))} ${themeColors.dim(`— ${t('router.v2.exactOrder')}`)}`)
503
504
  const routingOrder = Array.isArray(stats?.routingOrder) ? stats.routingOrder : []
504
505
  const models = Array.isArray(stats?.models) ? stats.models : []
505
506
  const healthByKey = new Map(models.map((m) => [m.key, m]))
@@ -507,11 +508,11 @@ export function renderRouterV2Dashboard(state, deps = {}) {
507
508
  const cursor = state.routerV2CursorIndex ?? 0
508
509
 
509
510
  if (!isRunning) {
510
- lines.push(` ${themeColors.dim('Start the daemon to see the live chain.')}`)
511
+ lines.push(` ${themeColors.dim(t('router.v2.startToSeeChain'))}`)
511
512
  } else if (routingOrder.length === 0) {
512
- lines.push(` ${themeColors.warning('No routeable candidates right now (keys missing or all models failing).')}`)
513
+ lines.push(` ${themeColors.warning(t('router.v2.emptyCandidates'))}`)
513
514
  } else {
514
- lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay('MODEL', 44))} ${themeColors.dim(padEndDisplay('STATE', 15))} ${themeColors.dim(padEndDisplay('UPTIME', 7))} ${themeColors.dim(padEndDisplay('V2 TEST', 12))} ${themeColors.dim('LAST ERROR')}`)
515
+ lines.push(` ${themeColors.dim(padEndDisplay('PRI', 4))} ${themeColors.dim(padEndDisplay(t('router.model'), 44))} ${themeColors.dim(padEndDisplay(t('router.state'), 15))} ${themeColors.dim(padEndDisplay(t('router.uptime'), 7))} ${themeColors.dim(padEndDisplay('V2 ' + t('router.v2.testViaRouter'), 12))} ${themeColors.dim(t('router.v2.lastError'))}`)
515
516
  const maxRows = Math.max(1, routingOrder.length)
516
517
  routingOrder.forEach((entry, i) => {
517
518
  const health = healthByKey.get(entry.key)
@@ -535,21 +536,21 @@ export function renderRouterV2Dashboard(state, deps = {}) {
535
536
 
536
537
  // ── Recent requests WITH fallback chains (the v1 gap) ───────────────────────
537
538
  lines.push('')
538
- lines.push(` ${themeColors.textBold('Request Chains')} ${themeColors.dim('- every attempt, skips and the winner')}`)
539
+ lines.push(` ${themeColors.textBold(t('router.v2.requestChains'))} ${themeColors.dim(`— ${t('router.v2.attemptsHint')}`)}`)
539
540
  const historyEntries = Array.isArray(state.routerV2History?.entries) ? state.routerV2History.entries : []
540
541
  if (!isRunning) {
541
- lines.push(` ${themeColors.dim('No history (daemon stopped).')}`)
542
+ lines.push(` ${themeColors.dim(t('router.v2.noHistory'))}`)
542
543
  } else if (historyEntries.length === 0) {
543
- lines.push(` ${themeColors.dim('No requests routed yet')}`)
544
+ lines.push(` ${themeColors.dim(t('router.noRequests'))}`)
544
545
  } else {
545
546
  for (const entry of historyEntries.slice(0, 6)) {
546
547
  const atMs = Date.parse(entry.at)
547
548
  const time = Number.isFinite(atMs) ? new Date(atMs).toLocaleTimeString() : '-'
548
549
  const outcome = entry.outcome === 'served'
549
- ? themeColors.success('served')
550
+ ? themeColors.success(t('router.v2.outcome.served'))
550
551
  : entry.outcome === 'client_aborted'
551
- ? themeColors.dim('aborted')
552
- : themeColors.error(entry.outcome || 'failed')
552
+ ? themeColors.dim(t('router.v2.outcome.aborted'))
553
+ : themeColors.error(t('router.v2.outcome.failed'))
553
554
  const chain = attemptChainLabel(entry)
554
555
  const skips = Array.isArray(entry.skipped) && entry.skipped.length > 0
555
556
  ? ` ${themeColors.dim(`[skips: ${entry.skipped.map((s) => s.reason).join(', ')}]`)}`
@@ -572,7 +573,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
572
573
  `🔐 auth ${stateCounts.AUTH_ERROR ?? 0}`,
573
574
  `🔥 quota ${stateCounts.QUOTA_PAUSED ?? 0}`,
574
575
  ]
575
- lines.push(` ${themeColors.textBold('Models:')} ${chips.map((c) => themeColors.dim(c)).join(' ')}`)
576
+ lines.push(` ${themeColors.textBold(`${t('router.models')}:`)} ${chips.map((c) => themeColors.dim(c)).join(' ')}`)
576
577
  const pauses = Array.isArray(stats?.quotaPauses) ? stats.quotaPauses : []
577
578
  if (pauses.length > 0) {
578
579
  for (const pause of pauses.slice(0, 3)) {
@@ -592,7 +593,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
592
593
  const isStopped = !isRunning && !isLoading
593
594
  const cursorBase = Math.max(1, routingOrder.length)
594
595
  const startBtnCursor = cursorBase
595
- const startBtnText = isStopped ? '▶ Start Router Daemon (v2 engine)' : '⏹ Stop Router Daemon (v2 engine)'
596
+ const startBtnText = isStopped ? `▶ ${t('router.start')} (v2)` : `⏹ ${t('router.stop')} (v2)`
596
597
  const startBtnRow = ` [ ${startBtnText} ]`
597
598
  lines.push(cursor === startBtnCursor
598
599
  ? themeColors.bgCursor(startBtnRow + ' '.repeat(Math.max(0, width - displayWidth(startBtnRow) - 3)))
@@ -616,7 +617,7 @@ export function renderRouterV2Dashboard(state, deps = {}) {
616
617
  lines.push('')
617
618
  lines.push(` ${separator}`)
618
619
  const probeMode = stats?.probeMode || 'balanced'
619
- lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('T')} ${themeColors.dim('Test via router')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start' : 'Stop')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Probes: ${probeMode}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear history')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
620
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim(t('settings.navigate'))} ${themeColors.dim('•')} ${themeColors.hotkey('T')} ${themeColors.dim(t('router.v2.testViaRouter'))} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? t('router.start') : t('router.stop'))} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`${t('router.v2.probes')}: ${probeMode}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim(t('router.clearHistory'))} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim(t('common.back'))}`)
620
621
  lines.push(` ${themeColors.dim('BETA: the v2 engine now powers the main router daemon. Ctrl+T tests the selected table model, Ctrl+Shift+T tests all visible models through the router.')}`)
621
622
 
622
623
  const { visible, offset } = sliceOverlayLines(lines, state.routerV2ScrollOffset || 0, state.terminalRows || 24)
@@ -628,5 +629,5 @@ export function renderRouterV2Dashboard(state, deps = {}) {
628
629
  function compactTextV2(value, width) {
629
630
  const text = String(value ?? '')
630
631
  if (displayWidth(text) <= width) return themeColors.dim(text)
631
- return themeColors.dim(`${text.slice(0, Math.max(1, width - 1))}…`)
632
+ return themeColors.dim(truncateAnsiWidth(text, width, { ellipsis: '…' }))
632
633
  }
@@ -41,6 +41,7 @@ import { join } from 'node:path'
41
41
  import * as readline from 'node:readline'
42
42
  import chalk from 'chalk'
43
43
  import { ENV_VAR_NAMES } from './provider-metadata.js'
44
+ import { isPureEnvPlaceholder } from './utils.js'
44
45
  import { saveConfig } from './config.js'
45
46
 
46
47
  // 📖 Unique marker used to identify the source line we inject into shell rc files.
@@ -126,6 +127,11 @@ export function buildEnvContent(config, shell) {
126
127
  const value = Array.isArray(rawValue) ? rawValue[0] : rawValue
127
128
  if (!value || typeof value !== 'string') continue
128
129
 
130
+ // 📖 Issue #196: a config value of "${MY_KEY}" means the key comes from the
131
+ // 📖 user's own environment. Never export the literal placeholder into the
132
+ // 📖 rc/env file, it would clobber the real variable on every shell start.
133
+ if (isPureEnvPlaceholder(value)) continue
134
+
129
135
  const safeValue = value.replace(/'/g, "'\\''")
130
136
 
131
137
  if (isFish) {
package/src/core/utils.js CHANGED
@@ -1168,3 +1168,25 @@ export function formatResultsAsJSON(results, sortBy = 'avg', limit = 0) {
1168
1168
 
1169
1169
  return JSON.stringify(formatted, null, 2)
1170
1170
  }
1171
+
1172
+ // 📖 Issue #196: ${VAR} placeholder support inside config apiKeys values.
1173
+ // 📖 The config file on disk keeps the literal placeholder, so saveConfig never
1174
+ // 📖 writes the resolved secret back; expansion happens only in memory, at the
1175
+ // 📖 moment a consumer reads the key (getApiKey, tool env sync). Only the
1176
+ // 📖 explicit ${NAME} form is recognized, so a literal key containing a bare
1177
+ // 📖 "$" is never mangled. An unset variable leaves the placeholder untouched:
1178
+ // 📖 probes will fail auth, which is the honest signal that something is off.
1179
+ export function resolveEnvPlaceholderValue(value) {
1180
+ if (typeof value !== 'string') return value
1181
+ return value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (placeholder, name) => {
1182
+ const resolved = process.env[name]
1183
+ return typeof resolved === 'string' ? resolved : placeholder
1184
+ })
1185
+ }
1186
+
1187
+ // 📖 True when the value is nothing but one ${VAR} reference. Used by the
1188
+ // 📖 shell rc/env generator to skip entries the user's own environment already
1189
+ // 📖 provides (exporting the literal placeholder would clobber the real var).
1190
+ export function isPureEnvPlaceholder(value) {
1191
+ return typeof value === 'string' && /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/.test(value.trim())
1192
+ }
package/src/tui/app.js CHANGED
@@ -95,6 +95,7 @@ import { randomUUID } from 'crypto'
95
95
  import { MODELS, sources } from '../../sources.js'
96
96
  import { getAvg, getVerdict, getUptime, getP95, getJitter, getStabilityScore, sortResults, filterByTier, parseArgs, TIER_ORDER, VERDICT_ORDER, TIER_LETTER_MAP, scoreModelForTask, getTopRecommendations, TASK_TYPES, PRIORITY_TYPES, CONTEXT_BUDGETS, formatResultsAsJSON } from '../core/utils.js'
97
97
  import { loadConfig, saveConfig, getApiKey, resolveApiKeys, addApiKey, removeApiKey, isProviderEnabled, persistApiKeysForProvider } from '../core/config.js'
98
+ import { setLocale } from '../core/i18n/index.js'
98
99
  import { buildMergedModels } from '../core/model-merger.js'
99
100
  import { loadOpenCodeConfig, saveOpenCodeConfig } from '../core/opencode-config.js'
100
101
  import { usageForRow as _usageForRow } from '../core/usage-reader.js'
@@ -256,6 +257,7 @@ const LOCAL_VERSION = pkg.version
256
257
  // 📖 OpenCode helpers are imported from ../src/opencode.js
257
258
 
258
259
  export async function runApp(cliArgs, config, startupOptions = {}) {
260
+ setLocale(config.settings?.language || 'en')
259
261
  // 📖 t4: prime the sync extended-benchmark stats so the footer chip is
260
262
  // 📖 accurate on the first render (no async, no network — just a sync read).
261
263
  ensureBenchStatsLoaded()
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { getToolModeOrder, getToolMeta } from '../core/tool-metadata.js'
22
+ import { t } from '../core/i18n/index.js'
22
23
 
23
24
  const ANALYSIS_FLAGS = [
24
25
  { flag: '--best', description: 'Show only top tiers (A+, S, S+)' },
@@ -71,6 +72,29 @@ const EXAMPLES = [
71
72
  "free-coding-models --json | jq '.[0]'",
72
73
  ]
73
74
 
75
+ const FLAG_DESCRIPTION_KEYS = Object.freeze({
76
+ '--best': 'cli.flag.best', '--fiable': 'cli.flag.fiable', '--json': 'cli.flag.json',
77
+ '--tier <S|A|B|C>': 'cli.flag.tier', '--recommend': 'cli.flag.recommend',
78
+ '--premium': 'cli.flag.premium', '--sort <column>': 'cli.flag.sort',
79
+ '--desc | --asc': 'cli.flag.direction', '--origin <provider>': 'cli.flag.origin',
80
+ '--ping-interval <ms>': 'cli.flag.pingInterval', '--hide-unconfigured': 'cli.flag.hideUnconfigured',
81
+ '--show-unconfigured': 'cli.flag.showUnconfigured', '--reprobe, --no-cache': 'cli.flag.reprobe',
82
+ '--probe-ttl <ms>': 'cli.flag.probeTtl', '--show-broken': 'cli.flag.showBroken',
83
+ '--check-drift': 'cli.flag.checkDrift', '--drift-threshold <N>': 'cli.flag.driftThreshold',
84
+ 'web | --web | --gui': 'cli.flag.web', 'playground | --playground': 'cli.flag.playground',
85
+ '--daemon': 'cli.flag.daemon', '--daemon-bg': 'cli.flag.daemonBg', '--daemon-status': 'cli.flag.daemonStatus',
86
+ '--daemon-stop': 'cli.flag.daemonStop', '--router-v2': 'cli.flag.routerV2', '--router-v2-bg': 'cli.flag.routerV2Bg',
87
+ '--router-v2-status': 'cli.flag.routerV2Status', '--router-v2-stop': 'cli.flag.routerV2Stop',
88
+ '--sync-set [name]': 'cli.flag.syncSet', '--config-dir <dir>': 'cli.flag.configDir',
89
+ '--fix-permissions, --yes, -y': 'cli.flag.fixPermissions', '--no-telemetry': 'cli.flag.noTelemetry',
90
+ '--help, -h': 'cli.flag.help',
91
+ })
92
+
93
+ function entryDescription(entry) {
94
+ const key = FLAG_DESCRIPTION_KEYS[entry.flag]
95
+ return key ? t(key) : entry.description
96
+ }
97
+
74
98
  /**
75
99
  * 📖 buildHowTheRouterWorks — a single-source explanation of the router
76
100
  * 📖 internals (circuit breaker, probe mechanism, pre-prompt) that the
@@ -83,10 +107,10 @@ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {})
83
107
  const body = (text) => `${indent}${paint(chalk, chalk?.dim, text)}`
84
108
  const bullet = (text) => `${indent} • ${text}`
85
109
 
86
- lines.push(header('How the FCM Router Works'))
110
+ lines.push(header(t('cli.routerHowItWorks')))
87
111
  lines.push('')
88
112
 
89
- lines.push(header('1. The smart router daemon'))
113
+ lines.push(header(`1. ${t('cli.routerDaemon')}`))
90
114
  lines.push(body('Point any OpenAI-compatible client at http://localhost:19280/v1'))
91
115
  lines.push(body('with model: "fcm". The daemon picks the healthiest model in'))
92
116
  lines.push(body('your active set and forwards the request — with automatic'))
@@ -95,14 +119,14 @@ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {})
95
119
  lines.push(body('Family failover on the Router Dashboard) before switching family.'))
96
120
  lines.push('')
97
121
 
98
- lines.push(header('2. The pre-prompt (system message)'))
122
+ lines.push(header(`2. ${t('cli.prePrompt')}`))
99
123
  lines.push(body('A first-class system message is injected on every proxied'))
100
124
  lines.push(body('request. The default text introduces the assistant as the FCM'))
101
125
  lines.push(body('routing agent and points the user to the dashboard URL.'))
102
126
  lines.push(body('You can edit it from Settings (Settings → Pre-prompt).'))
103
127
  lines.push('')
104
128
 
105
- lines.push(header('3. The probe mechanism (every 10s/30s/120s)'))
129
+ lines.push(header(`3. ${t('cli.probeMechanism')}`))
106
130
  lines.push(body('The daemon sends a 1-token chat-completion ping to every model'))
107
131
  lines.push(body('in the active set. The probe measures latency + status code, not'))
108
132
  lines.push(body('just URL reachability — so a wrong API key is caught and the'))
@@ -112,7 +136,7 @@ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {})
112
136
  lines.push(bullet('aggressive: probe every 10s (uses more quota)'))
113
137
  lines.push('')
114
138
 
115
- lines.push(header('4. The circuit breaker (per-model state)'))
139
+ lines.push(header(`4. ${t('cli.circuitBreaker')}`))
116
140
  lines.push(body('Each model has a tiny disjoncteur that flips between 3 states.'))
117
141
  lines.push(body('The raw jargon is hidden in the UI — here is what the colors mean:'))
118
142
  lines.push(bullet('Healthy (green) — last probe returned 2xx, route here freely'))
@@ -125,21 +149,21 @@ export function buildHowTheRouterWorksLines({ chalk = null, indent = '' } = {})
125
149
  lines.push(body('then falls through to any provider.'))
126
150
  lines.push('')
127
151
 
128
- lines.push(header('5. Failover order'))
152
+ lines.push(header(`5. ${t('cli.failoverOrder')}`))
129
153
  lines.push(body('Models in the active set are tried in priority order. A model'))
130
154
  lines.push(body('in Recovering/Down/Auth error is skipped — the request goes to'))
131
155
  lines.push(body('the next healthy one. If ALL models fail, you get a 503 with the'))
132
156
  lines.push(body('"models_tried" list in the error body — useful for debugging.'))
133
157
  lines.push('')
134
158
 
135
- lines.push(header('6. Auto-heal (default behavior)'))
159
+ lines.push(header(`6. ${t('cli.autoHeal')}`))
136
160
  lines.push(body('On daemon start, the active set is checked. Any model in Auth'))
137
161
  lines.push(body('error or Deprecated is swapped for a working alternative. The'))
138
162
  lines.push(body('first time you add/remove/reorder a model, auto-heal switches off'))
139
163
  lines.push(body('and your manual choices are preserved.'))
140
164
  lines.push('')
141
165
 
142
- lines.push(header('7. Rate limits (RPD / RPM / TPM)'))
166
+ lines.push(header(`7. ${t('cli.rateLimits')}`))
143
167
  lines.push(body('Each provider has its own quota. Common free-tier limits:'))
144
168
  lines.push(bullet('Groq on-demand: 14 400 RPD, 30 RPM per model'))
145
169
  lines.push(bullet('Mistral La Plateforme: 1 RPS, 1B TPM (experiment plan)'))
@@ -164,35 +188,35 @@ function formatEntry(label, description, { chalk = null, indent = '', labelWidth
164
188
  return `${indent}${coloredLabel} ${coloredDescription}`
165
189
  }
166
190
 
167
- export function buildCliHelpLines({ chalk = null, indent = '', title = 'CLI Help' } = {}) {
191
+ export function buildCliHelpLines({ chalk = null, indent = '', title = null } = {}) {
168
192
  const lines = []
169
193
  const launchFlags = getToolModeOrder()
170
194
  .map((mode) => getToolMeta(mode))
171
195
  .filter((meta) => meta.flag)
172
- .map((meta) => ({ flag: meta.flag, description: `${meta.label} mode` }))
196
+ .map((meta) => ({ flag: meta.flag, description: `${meta.label} ${t('cli.mode')}` }))
173
197
 
174
- lines.push(`${indent}${paint(chalk, chalk?.bold, title)}`)
175
- lines.push(`${indent}${paint(chalk, chalk?.dim, 'Usage: free-coding-models [apiKey] [options]')}`)
198
+ lines.push(`${indent}${paint(chalk, chalk?.bold, title || t('cli.helpTitle'))}`)
199
+ lines.push(`${indent}${paint(chalk, chalk?.dim, `${t('cli.usage')}: free-coding-models [apiKey] [options]`)}`)
176
200
  lines.push('')
177
- lines.push(`${indent}${paint(chalk, chalk?.bold, 'Tool Flags')}`)
201
+ lines.push(`${indent}${paint(chalk, chalk?.bold, t('cli.toolFlags'))}`)
178
202
  for (const entry of launchFlags) {
179
- lines.push(formatEntry(entry.flag, entry.description, { chalk, indent }))
203
+ lines.push(formatEntry(entry.flag, entryDescription(entry), { chalk, indent }))
180
204
  }
181
205
  lines.push('')
182
- lines.push(`${indent}${paint(chalk, chalk?.bold, 'Analysis Flags')}`)
206
+ lines.push(`${indent}${paint(chalk, chalk?.bold, t('cli.analysisFlags'))}`)
183
207
  for (const entry of ANALYSIS_FLAGS) {
184
- lines.push(formatEntry(entry.flag, entry.description, { chalk, indent }))
208
+ lines.push(formatEntry(entry.flag, entryDescription(entry), { chalk, indent }))
185
209
  }
186
210
  lines.push('')
187
- lines.push(`${indent}${paint(chalk, chalk?.bold, 'Config & Maintenance')}`)
211
+ lines.push(`${indent}${paint(chalk, chalk?.bold, t('cli.configMaintenance'))}`)
188
212
  for (const entry of CONFIG_FLAGS) {
189
- lines.push(formatEntry(entry.flag, entry.description, { chalk, indent }))
213
+ lines.push(formatEntry(entry.flag, entryDescription(entry), { chalk, indent }))
190
214
  }
191
215
  lines.push('')
192
- lines.push(`${indent}${paint(chalk, chalk?.dim, 'Default launcher with no tool flag: OpenCode CLI')}`)
193
- lines.push(`${indent}${paint(chalk, chalk?.dim, 'Flags can be combined: --openclaw --tier S --json')}`)
216
+ lines.push(`${indent}${paint(chalk, chalk?.dim, t('cli.defaultLauncher'))}`)
217
+ lines.push(`${indent}${paint(chalk, chalk?.dim, t('cli.combineFlags'))}`)
194
218
  lines.push('')
195
- lines.push(`${indent}${paint(chalk, chalk?.bold, 'Examples')}`)
219
+ lines.push(`${indent}${paint(chalk, chalk?.bold, t('cli.examples'))}`)
196
220
  for (const example of EXAMPLES) {
197
221
  lines.push(`${indent}${paint(chalk, chalk?.cyan, example)}`)
198
222
  }
@@ -18,6 +18,7 @@
18
18
  import { TOOL_METADATA, TOOL_MODE_ORDER } from '../core/tool-metadata.js'
19
19
  import { sources } from '../../sources.js'
20
20
  import { sanitizePingInterval } from './tui-state.js'
21
+ import { getLocale, hasTranslation, t } from '../core/i18n/index.js'
21
22
 
22
23
  // 📖 Max result rows the palette renders (overlays.js slices to this). The
23
24
  // 📖 cursor must never point past the rendered slice, so key-handler clamps
@@ -284,7 +285,24 @@ export function buildCommandPaletteTree(visibleModels = [], config = null) {
284
285
  }
285
286
  }
286
287
 
287
- return tree
288
+ const localize = (nodes) => nodes.map((node) => {
289
+ const labelKey = `palette.command.${node.id}`
290
+ const descriptionKey = `palette.description.${node.id}`
291
+ const isProviderModelsDescription = node.id.startsWith('filter-provider-')
292
+ && node.id !== 'filter-provider-cycle'
293
+ && node.id !== 'filter-provider-all'
294
+ const effectiveDescriptionKey = isProviderModelsDescription
295
+ ? 'palette.description.providerModels'
296
+ : descriptionKey
297
+ if (hasTranslation(getLocale(), labelKey)) node.label = t(labelKey)
298
+ if (hasTranslation(getLocale(), effectiveDescriptionKey)) {
299
+ const provider = isProviderModelsDescription ? node.description?.replace(/ models$/, '') : undefined
300
+ node.description = t(effectiveDescriptionKey, { provider })
301
+ }
302
+ if (Array.isArray(node.children)) node.children = localize(node.children)
303
+ return node
304
+ })
305
+ return localize(tree)
288
306
  }
289
307
 
290
308
  /**
@@ -23,6 +23,7 @@
23
23
 
24
24
  import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from '../core/tool-metadata.js'
25
25
  import { loadConfig, saveConfig, replaceConfigContents, getApiKey } from '../core/config.js'
26
+ import { setLocale } from '../core/i18n/index.js'
26
27
  import { sources } from '../../sources.js'
27
28
  import { join, dirname } from 'node:path'
28
29
  import { fileURLToPath } from 'node:url'
@@ -606,6 +607,14 @@ export function createKeyHandler(ctx) {
606
607
  applyThemeSetting(cycleThemeSetting(currentTheme))
607
608
  }
608
609
 
610
+ function cycleLanguage() {
611
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
612
+ const nextLanguage = state.config.settings.language === 'zh-CN' ? 'en' : 'zh-CN'
613
+ state.config.settings.language = nextLanguage
614
+ setLocale(nextLanguage)
615
+ saveConfig(state.config)
616
+ }
617
+
609
618
  function toggleStartupAiSpeedScan() {
610
619
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
611
620
  state.config.settings.runAiSpeedTestOnStartup = state.config.settings.runAiSpeedTestOnStartup !== true
@@ -2607,7 +2616,8 @@ export function createKeyHandler(ctx) {
2607
2616
  const providerKeys = Object.keys(sources)
2608
2617
  const updateRowIdx = providerKeys.length
2609
2618
  const themeRowIdx = updateRowIdx + 1
2610
- const favoritesModeRowIdx = themeRowIdx + 1
2619
+ const languageRowIdx = themeRowIdx + 1
2620
+ const favoritesModeRowIdx = languageRowIdx + 1
2611
2621
  const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
2612
2622
  const autoHideBrokenModelsRowIdx = startupAiSpeedScanRowIdx + 1
2613
2623
  const cleanupLegacyProxyRowIdx = autoHideBrokenModelsRowIdx + 1
@@ -2763,6 +2773,11 @@ export function createKeyHandler(ctx) {
2763
2773
  return
2764
2774
  }
2765
2775
 
2776
+ if (state.settingsCursor === languageRowIdx) {
2777
+ cycleLanguage()
2778
+ return
2779
+ }
2780
+
2766
2781
  if (state.settingsCursor === favoritesModeRowIdx) {
2767
2782
  toggleFavoritesDisplayMode()
2768
2783
  return
@@ -2828,6 +2843,10 @@ export function createKeyHandler(ctx) {
2828
2843
  cycleGlobalTheme()
2829
2844
  return
2830
2845
  }
2846
+ if (state.settingsCursor === languageRowIdx) {
2847
+ cycleLanguage()
2848
+ return
2849
+ }
2831
2850
  if (state.settingsCursor === favoritesModeRowIdx) {
2832
2851
  toggleFavoritesDisplayMode()
2833
2852
  return
@@ -2856,6 +2875,7 @@ export function createKeyHandler(ctx) {
2856
2875
  if (
2857
2876
  state.settingsCursor === updateRowIdx
2858
2877
  || state.settingsCursor === themeRowIdx
2878
+ || state.settingsCursor === languageRowIdx
2859
2879
  || state.settingsCursor === favoritesModeRowIdx
2860
2880
  || state.settingsCursor === startupAiSpeedScanRowIdx
2861
2881
  || state.settingsCursor === autoHideBrokenModelsRowIdx