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.
@@ -165,6 +165,11 @@ async function createZaiProxy(apiKey) {
165
165
  // ─── Shared OpenCode spawn helper ─────────────────────────────────────────────
166
166
 
167
167
  // 📖 spawnOpenCode: Resolve API keys + spawn opencode CLI with correct env.
168
+ // 📖 Never pass --model on the command line: opencode v2 rejects flags its
169
+ // 📖 entry points don't know ("Unrecognized flag: --model", issue #194) and
170
+ // 📖 `opencode web` never accepted it. Every caller writes `config.model`
171
+ // 📖 into opencode.json first, so a bare launch picks the right model on
172
+ // 📖 both v1 and v2.
168
173
  async function spawnOpenCode(args, providerKey, fcmConfig, existingZaiProxy = null) {
169
174
  const envVarName = ENV_VAR_NAMES[providerKey]
170
175
  const resolvedKey = getApiKey(fcmConfig, providerKey)
@@ -295,7 +300,7 @@ export async function startOpenCode(model, fcmConfig) {
295
300
  console.log(chalk.dim(' Starting OpenCode...'))
296
301
  console.log()
297
302
 
298
- await spawnOpenCode(['--model', modelRef], providerKey, fcmConfig)
303
+ await spawnOpenCode([], providerKey, fcmConfig)
299
304
  return
300
305
  }
301
306
 
@@ -359,7 +364,7 @@ export async function startOpenCode(model, fcmConfig) {
359
364
  console.log(chalk.dim(' Starting OpenCode...'))
360
365
  console.log()
361
366
 
362
- await spawnOpenCode(['--model', modelRef], providerKey, fcmConfig, zaiProxyServer)
367
+ await spawnOpenCode([], providerKey, fcmConfig, zaiProxyServer)
363
368
  return
364
369
  }
365
370
 
@@ -394,7 +399,7 @@ export async function startOpenCode(model, fcmConfig) {
394
399
  }
395
400
  console.log()
396
401
 
397
- await spawnOpenCode(['--model', zenModelRef], providerKey, fcmConfig)
402
+ await spawnOpenCode([], providerKey, fcmConfig)
398
403
  return
399
404
  }
400
405
 
@@ -584,7 +589,7 @@ export async function startOpenCode(model, fcmConfig) {
584
589
  console.log(chalk.dim(' Starting OpenCode...'))
585
590
  console.log()
586
591
 
587
- await spawnOpenCode(['--model', modelRef], providerKey, fcmConfig)
592
+ await spawnOpenCode([], providerKey, fcmConfig)
588
593
  }
589
594
 
590
595
  // ─── Start OpenCode Web ───────────────────────────────────────────────────────
@@ -640,7 +645,9 @@ export async function startOpenCodeWeb(model, fcmConfig) {
640
645
  console.log(chalk.dim(' Starting OpenCode Web...'))
641
646
  console.log()
642
647
 
643
- await spawnOpenCode(['web', '--model', modelRef], providerKey, fcmConfig)
648
+ // 📖 `opencode web` accepts no --model flag; the default written in
649
+ // 📖 opencode.json above is what the web UI starts with.
650
+ await spawnOpenCode(['web'], providerKey, fcmConfig)
644
651
  }
645
652
 
646
653
  // ─── Start OpenCode Desktop ───────────────────────────────────────────────────
@@ -672,9 +679,9 @@ export async function startOpenCodeDesktop(model, fcmConfig) {
672
679
  execFile(cmd, args, (err) => resolve(!err))
673
680
  })
674
681
  const launched =
675
- (await tryExecFile('opencode-desktop', ['--model', modelRef])) ||
676
- (await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode', '--model', modelRef])) ||
677
- (await tryExecFile('snap', ['run', 'opencode', '--model', modelRef])) ||
682
+ (await tryExecFile('opencode-desktop', [])) ||
683
+ (await tryExecFile('flatpak', ['run', 'ai.opencode.OpenCode'])) ||
684
+ (await tryExecFile('snap', ['run', 'opencode'])) ||
678
685
  (await tryExecFile('xdg-open', ['/usr/share/applications/opencode.desktop']))
679
686
  if (!launched) printDesktopLaunchError()
680
687
  return
package/src/core/ping.js CHANGED
@@ -200,6 +200,30 @@ async function isDisabledThinkingRejected(resp, req) {
200
200
  }
201
201
  }
202
202
 
203
+ // 📖 Pollinations paid-wall sniffing (issue #190): models that left the free
204
+ // 📖 tier answer HTTP 200 with the error embedded in the completion content
205
+ // ("The account behind this API key doesn't have enough credits. This model
206
+ // needs paid Pollen..."). Status codes alone mark them healthy, so sniff the
207
+ // body and surface 402 (Payment Required) instead. Scoped to pollinations to
208
+ // avoid false positives on other providers.
209
+ const POLLINATIONS_PAID_WALL_MARKERS = [
210
+ // 📖 Apostrophe-free fragments: the API may return straight ' or typographic ’
211
+ 'have enough credits',
212
+ 'needs paid pollen',
213
+ ]
214
+
215
+ async function isPollinationsPaidWall(resp) {
216
+ try {
217
+ const body = await resp.clone().json()
218
+ const content = body?.choices?.[0]?.message?.content
219
+ if (typeof content !== 'string') return false
220
+ const lower = content.toLowerCase()
221
+ return POLLINATIONS_PAID_WALL_MARKERS.some((marker) => lower.includes(marker))
222
+ } catch {
223
+ return false
224
+ }
225
+ }
226
+
203
227
  // 📖 ping: Send a single chat completion request to measure model availability and latency.
204
228
  // 📖 providerKey and url determine provider-specific request format.
205
229
  // 📖 apiKey can be null — in that case no Authorization header is sent.
@@ -234,7 +258,13 @@ export async function ping(apiKey, modelId, providerKey, url) {
234
258
  if (retryMs > 0) pauseProviderQuota(providerKey, retryMs)
235
259
  }
236
260
  // 📖 Normalize all HTTP 2xx statuses to "200" so existing verdict/avg logic still works.
237
- const code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
261
+ let code = resp.status >= 200 && resp.status < 300 ? '200' : String(resp.status)
262
+ // 📖 Pollinations paid-wall (issue #190): a 200 whose completion content is
263
+ // 📖 the credits error means the model is NOT usable on a free key. Report
264
+ // 📖 402 so rows show a failure instead of a fake-healthy green.
265
+ if (code === '200' && providerKey === 'pollinations' && (await isPollinationsPaidWall(resp))) {
266
+ code = '402'
267
+ }
238
268
  // 📖 Passive quota tracker (t2): parse the response headers via the shared module.
239
269
  // 📖 1) Write to the passive snapshot map so /stats + TUI footer see live quota.
240
270
  // 📖 2) Return the percent number for the per-call consumer below.
@@ -21,10 +21,11 @@
21
21
  * @see ../tui/overlays.js — overlay factory that mounts this renderer
22
22
  */
23
23
 
24
- import { displayWidth, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
24
+ import { displayWidth, sliceOverlayLines, tintOverlayLines, truncateAnsiWidth } from '../tui/render-helpers.js'
25
25
  import { ROUTER_PORT_PATH, getRouterPortPath } from './router-daemon.js'
26
26
  import { existsSync, readFileSync } from 'node:fs'
27
27
  import { themeColors } from '../tui/theme.js'
28
+ import { t } from './i18n/index.js'
28
29
 
29
30
  // 📖 Width budget for the wrapped input + transcript columns inside the
30
31
  // 📖 overlay. Slightly tighter than the full terminal so borders have room
@@ -162,7 +163,7 @@ export async function playgroundSubmit(state, deps = {}) {
162
163
 
163
164
  const port = await readDaemonPort()
164
165
  if (!port) {
165
- PLAYGROUND_OVERLAY_STATE.lastError = 'Router is not running. Press R or run `free-coding-models --daemon-bg`.'
166
+ PLAYGROUND_OVERLAY_STATE.lastError = t('playground.routerStartError')
166
167
  return
167
168
  }
168
169
 
@@ -173,7 +174,7 @@ export async function playgroundSubmit(state, deps = {}) {
173
174
  PLAYGROUND_OVERLAY_STATE.cursor = 0
174
175
  PLAYGROUND_OVERLAY_STATE.busy = true
175
176
  PLAYGROUND_OVERLAY_STATE.lastError = null
176
- PLAYGROUND_OVERLAY_STATE.statusMessage = 'Sending…'
177
+ PLAYGROUND_OVERLAY_STATE.statusMessage = t('playground.sending')
177
178
 
178
179
  const controller = new AbortController()
179
180
  PLAYGROUND_OVERLAY_STATE.abortController = controller
@@ -403,22 +404,22 @@ export function renderPlayground(state, terminalRows, terminalCols) {
403
404
  const innerWidth = Math.max(40, terminalCols - 8)
404
405
 
405
406
  // 📖 Header
406
- lines.push(themeColors.accentBold(' 💬 Playground — chat with the FCM router'))
407
- lines.push(themeColors.dim(' Press Enter to send · Shift+Tab cycles model · Ctrl+S toggles streaming · Esc closes · Ctrl+L clears'))
407
+ lines.push(themeColors.accentBold(` 💬 ${t('playground.title')} — ${t('playground.chatViaRouter')}`))
408
+ lines.push(themeColors.dim(` ${t('playground.tuiHotkeys')}`))
408
409
  lines.push('')
409
410
 
410
411
  // 📖 Persona pill (one liner preview of the pre-prompt)
411
412
  const pre = PLAYGROUND_OVERLAY_STATE.prePrompt
412
413
  if (pre && pre.enabled && pre.text) {
413
- const preview = pre.text.length > innerWidth - 16 ? `${pre.text.slice(0, innerWidth - 19)}…` : pre.text
414
- lines.push(themeColors.dim(` Persona: ${preview.replace(/\n+/g, ' ')}`))
414
+ const preview = displayWidth(pre.text) > innerWidth - 16 ? truncateAnsiWidth(pre.text.replace(/\n+/g, ' '), innerWidth - 16, { ellipsis: '…' }) : pre.text.replace(/\n+/g, ' ')
415
+ lines.push(themeColors.dim(` ${t('playground.persona')}: ${preview}`))
415
416
  } else {
416
- lines.push(themeColors.dim(' Persona: (none)'))
417
+ lines.push(themeColors.dim(` ${t('playground.persona')}: ${t('common.none')}`))
417
418
  }
418
419
 
419
420
  // 📖 Model + mode row
420
- const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? 'streaming' : 'one-shot'
421
- lines.push(themeColors.dim(` Model: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
421
+ const mode = PLAYGROUND_OVERLAY_STATE.streamOn ? t('playground.streamOn') : t('playground.streamOff')
422
+ lines.push(themeColors.dim(` ${t('playground.model')}: ${PLAYGROUND_OVERLAY_STATE.model} · ${mode}`))
422
423
  if (PLAYGROUND_OVERLAY_STATE.lastError) {
423
424
  lines.push(themeColors.errorBold(` ⚠ ${PLAYGROUND_OVERLAY_STATE.lastError}`))
424
425
  }
@@ -428,7 +429,7 @@ export function renderPlayground(state, terminalRows, terminalCols) {
428
429
  const transcriptLines = []
429
430
  for (const msg of PLAYGROUND_OVERLAY_STATE.messages) {
430
431
  const role = msg.role === 'user'
431
- ? themeColors.accentBold(' ❯ you')
432
+ ? themeColors.accentBold(` ❯ ${t('playground.you')}`)
432
433
  : msg.role === 'assistant'
433
434
  ? themeColors.dim(' ✦ fcm')
434
435
  : themeColors.dim(` · ${msg.role}`)
@@ -439,18 +440,18 @@ export function renderPlayground(state, terminalRows, terminalCols) {
439
440
  }
440
441
  if (msg.role === 'assistant' && msg.meta) {
441
442
  const metaChips = []
442
- if (msg.meta.provider) metaChips.push(`routed ${msg.meta.provider}/${msg.meta.model || '?'}`)
443
+ if (msg.meta.provider) metaChips.push(`${t('playground.routed')} ${msg.meta.provider}/${msg.meta.model || '?'}`)
443
444
  if (msg.meta.latencyMs != null) metaChips.push(`${msg.meta.latencyMs}ms`)
444
- if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} tok`)
445
- if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} fallback`)
445
+ if (msg.meta.tokens) metaChips.push(`${msg.meta.tokens} ${t('playground.tokensShort')}`)
446
+ if (msg.meta.fallbackAttempts) metaChips.push(`${msg.meta.fallbackAttempts} ${t('playground.fallbackShort')}`)
446
447
  if (metaChips.length) {
447
448
  transcriptLines.push(themeColors.dim(` [ ${metaChips.join(' · ')} ]`))
448
449
  }
449
450
  if (msg.meta.aborted) {
450
- transcriptLines.push(themeColors.dim(' [ stopped ]'))
451
+ transcriptLines.push(themeColors.dim(` [ ${t('playground.stopped')} ]`))
451
452
  }
452
453
  if (msg.meta.error) {
453
- transcriptLines.push(themeColors.error(` [ error: ${msg.meta.error} ]`))
454
+ transcriptLines.push(themeColors.error(` [ ${t('common.error')}: ${msg.meta.error} ]`))
454
455
  }
455
456
  }
456
457
  transcriptLines.push('')
@@ -465,16 +466,16 @@ export function renderPlayground(state, terminalRows, terminalCols) {
465
466
  // 📖 Input box
466
467
  lines.push(themeColors.dim(' ─'.repeat(Math.max(8, Math.floor(innerWidth / 4)))))
467
468
  if (PLAYGROUND_OVERLAY_STATE.busy) {
468
- lines.push(themeColors.accent(' ⏳ waiting for response — Esc to stop'))
469
+ lines.push(themeColors.accent(` ⏳ ${t('playground.waitingResponse')}`))
469
470
  } else {
470
- const draft = PLAYGROUND_OVERLAY_STATE.draft || 'Type your message and press Enter…'
471
+ const draft = PLAYGROUND_OVERLAY_STATE.draft || t('playground.draftPlaceholder')
471
472
  const draftDisplay = PLAYGROUND_OVERLAY_STATE.draft ? draft : themeColors.dim(draft)
472
473
  const wrappedDraft = wrapMessage(draftDisplay, innerWidth - 4)
473
474
  for (const line of wrappedDraft) {
474
475
  lines.push(` ❯ ${line}`)
475
476
  }
476
477
  if (!PLAYGROUND_OVERLAY_STATE.draft) {
477
- lines.push(themeColors.dim(' (Enter to send · Shift+Tab for a pinned model)'))
478
+ lines.push(themeColors.dim(` (${t('playground.enterToSend')} · Shift+Tab ${t('playground.pinModel')})`))
478
479
  }
479
480
  }
480
481
 
@@ -70,6 +70,7 @@ export const PROVIDER_AUTH_ENDPOINTS = {
70
70
  requesty: null,
71
71
  orcarouter: { url: 'https://api.orcarouter.ai/v1/models', method: 'GET' },
72
72
  'vercel-gateway': { url: 'https://ai-gateway.vercel.sh/v1/models', method: 'GET' },
73
+ onomeo: { url: 'https://onomeo.com/v1/models', method: 'GET' },
73
74
  together: { url: 'https://api.together.xyz/v1/models', method: 'GET' },
74
75
  perplexity: { url: 'https://api.perplexity.ai/v1/models', method: 'GET' },
75
76
  chutes: { url: 'https://chutes.ai/v1/models', method: 'GET' },
@@ -67,6 +67,7 @@ export const ENV_VAR_NAMES = {
67
67
  requesty: 'REQUESTY_API_KEY',
68
68
  orcarouter: 'ORCAROUTER_API_KEY',
69
69
  'vercel-gateway': 'VERCEL_AI_GATEWAY_API_KEY',
70
+ onomeo: 'ONOMEO_API_KEY',
70
71
  // 📖 Providers whose OpenCode/Kilo config writers reference {env:VAR} - names
71
72
  // 📖 must match those tables exactly (see opencode.js / kilo.js).
72
73
  huggingface: 'HUGGINGFACE_API_KEY',
@@ -358,6 +359,14 @@ export const PROVIDER_METADATA = {
358
359
  rateLimits: 'Free tier · $5 credits/30 days + $0 models',
359
360
  detailedLimits: 'Official Vercel gateway at list prices, zero markup.\nEvery account gets $5 of credits every 30 days, no card required.\nThe monthly credit covers a subset of the catalog; the explicit $0 models stay free.\nCaution: purchasing credits once permanently moves the account to the paid tier and the monthly free credit stops.',
360
361
  },
362
+ onomeo: {
363
+ label: 'onomeo',
364
+ color: chalk.rgb(240, 120, 60),
365
+ signupUrl: 'https://onomeo.com/dashboard',
366
+ signupHint: 'Sign in (email, Google or GitHub, no card) → check in daily → Dashboard → API key',
367
+ rateLimits: 'Daily check-in credits (50,000-200,000/day) · 12 req/min per key · 60 req/5h per account',
368
+ detailedLimits: 'Public beta: not every feature is guaranteed to work; feedback is welcome at https://onomeo.com/feedback.\nCredits come from a daily check-in: 50,000 on day 1, rising to 200,000/day from day 7 of a streak. No card needed.\nEvery call spends credits and the cost per reply varies by model (see https://onomeo.com/models), so this is a small allowance. Each account that has not paid can spend up to 50,000 credits/day on premium models. Optional: $5 one-time buys 1,000,000 credits.\nLimits: 12 req/min per key, 60 req per 5 hours per account, 120 per 5 hours per IP, 450 per 5 hours site-wide for accounts that have not bought credits. Health probes count against them.\nSome upstreams may train on prompts; each model page on onomeo.com says which.',
369
+ },
361
370
  'ollama-cloud': {
362
371
  label: 'Ollama Cloud',
363
372
  color: chalk.rgb(230, 230, 230),
@@ -67,6 +67,8 @@ export const PROVIDER_CAPABILITIES = {
67
67
  orcarouter: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'unknown' },
68
68
  // 📖 Vercel AI Gateway exposes GET /v1/credits but no fetcher is wired yet; treat as monthly reset.
69
69
  'vercel-gateway': { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'monthly' },
70
+ // 📖 onomeo: daily check-in credits; balance is at GET /api/me but no fetcher is wired yet.
71
+ onomeo: { telemetryType: 'unknown', supportsEndpoint: false, usageDisplay: 'ok', resetCadence: 'daily' },
70
72
  }
71
73
 
72
74
  /** Fallback for unrecognized providers */
@@ -55,6 +55,8 @@ import { TIER_ORDER } from './utils.js'
55
55
  import { atomicWriteJson, safeJsonParse, sleep, maskApiKey, isRouteableProvider } from './shared-helpers.js'
56
56
  import { normalizeRequestBody } from './schema-normalizer.js'
57
57
  import { pickNextCandidate } from './model-family.js'
58
+ // 📖 Shared install-target list (same order the TUI Z-cycle and web/server.js use)
59
+ import { getInstallTargetModes } from './endpoint-installer.js'
58
60
  import {
59
61
  loadCache as loadProbeCache,
60
62
  flushCache as flushProbeCache,
@@ -3686,12 +3688,41 @@ export class RouterRuntime {
3686
3688
  sendJson(res, 200, getWebModelsPayload(this), { 'x-request-id': requestId })
3687
3689
  return
3688
3690
  }
3689
- // 📖 Stub endpoints for the web dashboard's hooks (useToolMode, useFavorites,
3690
- // 📖 useUpdateChecker). These were 404 before - minimal shapes that match
3691
- // 📖 what the dashboard hooks expect. See PR #108 for context.
3692
- if (req.method === 'GET' && (url.pathname === '/api/tool-mode')) {
3693
- sendJson(res, 200, { mode: 'opencode', tools: ['opencode', 'openclaw', 'opencode-desktop', 'opencode-web'] }, { 'x-request-id': requestId })
3694
- return
3691
+ // 📖 Web dashboard hooks (useToolMode, useFavorites, useUpdateChecker).
3692
+ // These were 404 before - minimal shapes that match what the dashboard
3693
+ // hooks expect. See PR #108 for context.
3694
+ // 📖 /api/tool-mode must handle POST too: useUrlState re-persists
3695
+ // `?toolMode=` from the URL on load, and a POST falling through to the
3696
+ // OpenAI-style 404 catch-all showed "[object Object]" toasts (issue #189).
3697
+ if (url.pathname === '/api/tool-mode') {
3698
+ const webToolModes = getInstallTargetModes()
3699
+ const normalizeToolMode = (m) => (typeof m === 'string' && webToolModes.includes(m) ? m : 'opencode')
3700
+ if (req.method === 'GET') {
3701
+ sendJson(res, 200, { mode: normalizeToolMode(this.config?.settings?.preferredToolMode), tools: webToolModes }, { 'x-request-id': requestId })
3702
+ return
3703
+ }
3704
+ if (req.method === 'POST') {
3705
+ if (!isSameOriginOrLocal(req)) {
3706
+ sendError(res, 403, 'Forbidden cross-origin request', 'invalid_request_error', 'forbidden_origin', requestId)
3707
+ return
3708
+ }
3709
+ const body = await readJsonBody(req)
3710
+ if (!body || typeof body.mode !== 'string' || !webToolModes.includes(body.mode)) {
3711
+ sendError(res, 422, 'Invalid tool mode', 'invalid_request_error', 'invalid_tool_mode', requestId)
3712
+ return
3713
+ }
3714
+ const mode = normalizeToolMode(body.mode)
3715
+ if (!this.config || typeof this.config !== 'object') this.config = {}
3716
+ if (!this.config.settings || typeof this.config.settings !== 'object') this.config.settings = {}
3717
+ this.config.settings.preferredToolMode = mode
3718
+ const saveResult = this.saveRouterConfig()
3719
+ if (!saveResult?.success) {
3720
+ sendError(res, 500, saveResult?.error || 'Failed to save tool mode', 'api_error', 'save_failed', requestId)
3721
+ return
3722
+ }
3723
+ sendJson(res, 200, { mode }, { 'x-request-id': requestId })
3724
+ return
3725
+ }
3695
3726
  }
3696
3727
  if (req.method === 'GET' && (url.pathname === '/api/favorites')) {
3697
3728
  const cfg = this.config || {}
@@ -44,6 +44,7 @@ import chalk from 'chalk'
44
44
  import { existsSync, readFileSync } from 'node:fs'
45
45
  import { displayWidth, padEndDisplay, sliceOverlayLines, tintOverlayLines } from '../tui/render-helpers.js'
46
46
  import { ROUTER_DEFAULT_PORT, ROUTER_MAX_PORT, getRouterPidPath, getRouterPortPath, getRouterPortRange } from './router-daemon.js'
47
+ import { t } from './i18n/index.js'
47
48
  import { themeColors, getTierRgb } from '../tui/theme.js'
48
49
  import { formatTokenTotalCompact } from './token-usage-reader.js'
49
50
  import { sendUsageTelemetry } from './telemetry.js'
@@ -871,12 +872,12 @@ export function renderRouterDashboard(state, deps = {}) {
871
872
  const { defaultPort: currentDefaultPort } = getRouterPortRange()
872
873
  const port = snapshot.port || state.routerDashboardPort || currentDefaultPort
873
874
  const baseUrl = `http://localhost:${port}/v1`
874
- lines.push(` ${themeColors.textBold('Quick Setup')} ${themeColors.dim('— paste into your coding tool config')}`)
875
+ lines.push(` ${themeColors.textBold(t('router.quickSetup'))} ${themeColors.dim(`— ${t('router.pasteIntoToolConfig')}`)}`)
875
876
  lines.push(` ${themeColors.dim('URL')} ${themeColors.infoBold(baseUrl)}`)
876
- lines.push(` ${themeColors.dim('Model')} ${themeColors.infoBold('fcm')}`)
877
+ lines.push(` ${themeColors.dim(t('router.model'))} ${themeColors.infoBold('fcm')}`)
877
878
  lines.push(` ${themeColors.dim('API Key')} ${themeColors.infoBold('fcm-local')}`)
878
879
  if (isRunning) {
879
- lines.push(` ${themeColors.dim('Uptime')} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim('Requests routed:')} ${themeColors.info(String(snapshot.requestsRouted))}`)
880
+ lines.push(` ${themeColors.dim(t('router.uptime'))} ${themeColors.success(formatRouterDuration(snapshot.uptimeSeconds))} ${themeColors.dim(`${t('router.requests')}:`)} ${themeColors.info(String(snapshot.requestsRouted))}`)
880
881
  } else {
881
882
  lines.push(` ${themeColors.dim('Hint')} ${themeColors.dim('Start the daemon to enable routing')}`)
882
883
  }
@@ -886,7 +887,7 @@ export function renderRouterDashboard(state, deps = {}) {
886
887
  // ── Favorites / Router Fallback Models ──────────────────────────────────────
887
888
  // 📖 Instead of the old "sets" system, show the user's favorites from the main
888
889
  // 📖 table as the router fallback chain. #1 = tried first, #2 = next, etc.
889
- lines.push(` ${themeColors.textBold('Router Models')} ${themeColors.dim('— your favorites, in fallback order')}`)
890
+ lines.push(` ${themeColors.textBold(t('router.routerModels'))} ${themeColors.dim(`— ${t('router.favoritesFallbackOrder')}`)}`)
890
891
  lines.push(` ${themeColors.dim('Star models with F in the main table. Shift+↑↓ to reorder here.')}`)
891
892
  lines.push('')
892
893
 
@@ -895,7 +896,7 @@ export function renderRouterDashboard(state, deps = {}) {
895
896
  const cursor = state.routerDashboardCursorIndex ?? 0
896
897
 
897
898
  if (favorites.length === 0) {
898
- lines.push(` ${themeColors.warning('No favorites yet. Press Esc, then F on any model to add it.')}`)
899
+ lines.push(` ${themeColors.warning(t('router.noFavoritesTui'))}`)
899
900
  lines.push(` ${themeColors.dim('Favorites become your router fallback chain — #1 is tried first.')}`)
900
901
  } else {
901
902
  // 📖 Priority keycap glyphs for the fallback order
@@ -1010,7 +1011,7 @@ export function renderRouterDashboard(state, deps = {}) {
1010
1011
  lines.push('')
1011
1012
  const btnCursor = favorites.length
1012
1013
  const isBtnCursor = cursor === btnCursor
1013
- const btnText = isStopped ? '▶ Start Router Daemon' : '⏹ Stop Router Daemon'
1014
+ const btnText = isStopped ? `▶ ${t('router.start')}` : `⏹ ${t('router.stop')}`
1014
1015
  const btnRowText = ` [ ${btnText} ]`
1015
1016
 
1016
1017
  if (isBtnCursor) {
@@ -1022,7 +1023,7 @@ export function renderRouterDashboard(state, deps = {}) {
1022
1023
  // 📖 Install Endpoint Button
1023
1024
  const installBtnCursor = favorites.length + 1
1024
1025
  const isInstallBtnCursor = cursor === installBtnCursor
1025
- const installBtnText = '🔌 Install Router Endpoint to CLI Tool'
1026
+ const installBtnText = `🔌 ${t('router.installEndpointCli')}`
1026
1027
  const installBtnRowText = ` [ ${installBtnText} ]`
1027
1028
 
1028
1029
  if (isInstallBtnCursor) {
@@ -1036,11 +1037,11 @@ export function renderRouterDashboard(state, deps = {}) {
1036
1037
  lines.push('')
1037
1038
 
1038
1039
  // ── Token Summary (compact, visual) ─────────────────────────────────────────
1039
- lines.push(` ${themeColors.textBold('📊 Tokens')} ${themeColors.dim('Today:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.today.requests} req)`)} ${themeColors.dim('Lifetime:')} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(`(${snapshot.tokens.all_time.requests} req)`)}`)
1040
+ lines.push(` ${themeColors.textBold('📊 Token')} ${themeColors.dim(`${t('analytics.today')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.today.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.today.requests }))} ${themeColors.dim(`${t('router.lifetime')}:`)} ${themeColors.info(formatTokenTotalCompact(snapshot.tokens.all_time.total_tokens))} ${themeColors.dim(t('analytics.requestsShort', { count: snapshot.tokens.all_time.requests }))}`)
1040
1041
 
1041
1042
  // ── Active Requests ────────────────────────────────────────────────────────
1042
1043
  lines.push('')
1043
- lines.push(` ${themeColors.warningBold('⚡ Active Requests')} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
1044
+ lines.push(` ${themeColors.warningBold(`⚡ ${t('router.activeRequests')}`)} ${themeColors.dim(`(${snapshot.activeRequests?.length || 0})`)}`)
1044
1045
  if (snapshot.activeRequests?.length > 0) {
1045
1046
  for (const req of snapshot.activeRequests) {
1046
1047
  const model = compactText(req.model, 24)
@@ -1052,15 +1053,15 @@ export function renderRouterDashboard(state, deps = {}) {
1052
1053
  lines.push(` ${themeColors.dim('•')} ${shortId}${model} → ${themeColors.info(current)} ${status}${tokens} ${themeColors.dim(duration)}`)
1053
1054
  }
1054
1055
  } else {
1055
- lines.push(` ${themeColors.dim('No active requests')}`)
1056
+ lines.push(` ${themeColors.dim(t('router.noActiveRequests'))}`)
1056
1057
  }
1057
1058
 
1058
1059
  // ── Live Request Log (compact) ──────────────────────────────────────────────
1059
1060
  const requestRows = requestLogRows(state, snapshot)
1060
1061
  lines.push('')
1061
- lines.push(` ${themeColors.textBold('Recent Requests')}`)
1062
+ lines.push(` ${themeColors.textBold(t('router.recentRequests'))}`)
1062
1063
  if (requestRows.length > 0) {
1063
- const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay('Time', 10)} ${padEndDisplay('Model', 34)} ${padEndDisplay('Status', 8)} ${padEndDisplay('Latency', 9)} Detail`
1064
+ const header = ` ${padEndDisplay('ID', 5)} ${padEndDisplay(t('router.time'), 10)} ${padEndDisplay(t('router.model'), 34)} ${padEndDisplay(t('router.state'), 8)} ${padEndDisplay(t('router.lastLatency'), 9)} ${t('router.detail')}`
1064
1065
  lines.push(themeColors.dim(header))
1065
1066
  for (const row of requestRows.slice(0, 6)) {
1066
1067
  const atMs = Date.parse(row.at)
@@ -1086,7 +1087,7 @@ export function renderRouterDashboard(state, deps = {}) {
1086
1087
  )
1087
1088
  }
1088
1089
  } else {
1089
- lines.push(` ${themeColors.dim('No requests routed yet')}`)
1090
+ lines.push(` ${themeColors.dim(t('router.noRequests'))}`)
1090
1091
  }
1091
1092
 
1092
1093
  // ── Health check speed ──────────────────────────────────────────────────────
@@ -1111,7 +1112,7 @@ export function renderRouterDashboard(state, deps = {}) {
1111
1112
  // ── Footer ──────────────────────────────────────────────────────────────────
1112
1113
  lines.push('')
1113
1114
  lines.push(` ${separator}`)
1114
- lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim('Navigate')} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim('Reorder')} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? 'Start daemon' : 'Stop daemon')} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`Health check: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim('Clear log')} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim('Sync best')} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim('Back')}`)
1115
+ lines.push(` ${themeColors.hotkey('↑↓')} ${themeColors.dim(t('settings.navigate'))} ${themeColors.dim('•')} ${themeColors.hotkey('Shift+↑↓')} ${themeColors.dim(t('router.reorder'))} ${themeColors.dim('•')} ${themeColors.hotkey('S')} ${themeColors.dim(isStopped ? t('router.start') : t('router.stop'))} ${themeColors.dim('•')} ${themeColors.hotkey('I')} ${themeColors.dim(`${t('router.healthCheck')}: ${probeLabel}`)} ${themeColors.dim('•')} ${themeColors.hotkey('C')} ${themeColors.dim(t('router.clearLog'))} ${themeColors.dim('•')} ${themeColors.hotkey('R')} ${themeColors.dim(t('router.syncBest'))} ${themeColors.dim('•')} ${themeColors.hotkey('Esc')} ${themeColors.dim(t('common.back'))}`)
1115
1116
 
1116
1117
  const { visible, offset } = sliceOverlayLines(lines, state.routerDashboardScrollOffset || 0, state.terminalRows || 24)
1117
1118
  state.routerDashboardScrollOffset = offset
@@ -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
  }
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()