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.
@@ -26,6 +26,7 @@ import { renderPlayground as renderPlaygroundOverlay } from '../core/playground.
26
26
  import { themeColors, getThemeStatusLabel, getProviderRgb } from './theme.js'
27
27
  import { getProviderBillingNote, getProviderLabelWithBilling } from '../core/provider-metadata.js'
28
28
  import { pickAccountIdFromSettings } from '../core/cloudflare-account.js'
29
+ import { t } from '../core/i18n/index.js'
29
30
  import { detectTerminalCapabilities } from '../core/utils.js'
30
31
  import { truncateAnsiWidth, loadChangelogCached } from './render-helpers.js'
31
32
  import { COMMAND_PALETTE_MAX_RESULTS } from './command-palette.js'
@@ -72,22 +73,20 @@ export function createOverlayRenderers(state, deps) {
72
73
  // πŸ“– the overlay instead of turning into one truncated red line.
73
74
  // πŸ“– Uses 100% of terminal width minus padding for better readability.
74
75
  const wrapPlainText = (text, width = null) => {
75
- const effectiveWidth = width || (state.terminalCols - 16)
76
+ const effectiveWidth = Math.max(1, width || (state.terminalCols - 16))
76
77
  const normalized = typeof text === 'string' ? text.trim() : ''
77
78
  if (!normalized) return []
78
- const words = normalized.split(/\s+/)
79
79
  const lines = []
80
80
  let current = ''
81
- for (const word of words) {
82
- const next = current ? `${current} ${word}` : word
83
- if (next.length > effectiveWidth && current) {
84
- lines.push(current)
85
- current = word
81
+ for (const character of [...normalized]) {
82
+ if (displayWidth(current + character) > effectiveWidth && current) {
83
+ lines.push(current.trimEnd())
84
+ current = character.trimStart()
86
85
  } else {
87
- current = next
86
+ current += character
88
87
  }
89
88
  }
90
- if (current) lines.push(current)
89
+ if (current.trim()) lines.push(current.trimEnd())
91
90
  return lines
92
91
  }
93
92
 
@@ -123,7 +122,8 @@ export function createOverlayRenderers(state, deps) {
123
122
  const providerKeys = Object.keys(sources)
124
123
  const updateRowIdx = providerKeys.length
125
124
  const themeRowIdx = updateRowIdx + 1
126
- const favoritesModeRowIdx = themeRowIdx + 1
125
+ const languageRowIdx = themeRowIdx + 1
126
+ const favoritesModeRowIdx = languageRowIdx + 1
127
127
  const startupAiSpeedScanRowIdx = favoritesModeRowIdx + 1
128
128
  const autoHideBrokenModelsRowIdx = startupAiSpeedScanRowIdx + 1
129
129
  const cleanupLegacyProxyRowIdx = autoHideBrokenModelsRowIdx + 1
@@ -135,14 +135,14 @@ export function createOverlayRenderers(state, deps) {
135
135
 
136
136
  // πŸ“– Branding header
137
137
  lines.push(` ${themeColors.accent('πŸš€')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
138
- lines.push(` ${themeColors.textBold('βš™ Settings')}`)
138
+ lines.push(` ${themeColors.textBold(`βš™ ${t('settings.title')}`)}`)
139
139
 
140
140
  if (state.settingsErrorMsg) {
141
141
  lines.push(` ${themeColors.errorBold(state.settingsErrorMsg)}`)
142
142
  lines.push('')
143
143
  }
144
144
 
145
- lines.push(` ${themeColors.textBold('🧩 Providers')}`)
145
+ lines.push(` ${themeColors.textBold(`🧩 ${t('settings.providers')}`)}`)
146
146
  // πŸ“– Dynamic separator line using 100% terminal width
147
147
  const separatorWidth = Math.max(20, state.terminalCols - 10)
148
148
  lines.push(` ${themeColors.dim(' ' + '─'.repeat(separatorWidth))}`)
@@ -174,25 +174,25 @@ export function createOverlayRenderers(state, deps) {
174
174
  const extra = keyCount > 1 ? themeColors.info(` (+${keyCount - 1} more)`) : ''
175
175
  keyDisplay = keyMasked + extra
176
176
  } else {
177
- keyDisplay = themeColors.dim('(no key set)')
177
+ keyDisplay = themeColors.dim(t('settings.noApiKeySet'))
178
178
  }
179
179
 
180
180
  // πŸ“– Test result badge
181
181
  const testResult = state.settingsTestResults[pk]
182
182
  // πŸ“– Default badge reflects configuration first: a saved key should look
183
183
  // πŸ“– ready to test even before the user has run the probe once.
184
- let testBadge = keyCount > 0 ? themeColors.info('[Test]') : themeColors.dim('[Missing Key πŸ”‘]')
185
- if (testResult === 'pending') testBadge = themeColors.warning('[Testing…]')
186
- else if (testResult === 'ok') testBadge = themeColors.successBold('[Test βœ…]')
187
- else if (testResult === 'missing_key') testBadge = themeColors.dim('[Missing Key πŸ”‘]')
188
- else if (testResult === 'auth_error') testBadge = themeColors.error('[Auth ❌]')
189
- else if (testResult === 'rate_limited') testBadge = themeColors.warning('[Rate limit ⏳]')
190
- else if (testResult === 'no_callable_model') testBadge = chalk.rgb(...getProviderRgb('openrouter'))('[No model ⚠]')
191
- else if (testResult === 'fail') testBadge = themeColors.error('[Test ❌]')
184
+ let testBadge = keyCount > 0 ? themeColors.info(t('settings.badge.test')) : themeColors.dim(t('settings.badge.missingKey'))
185
+ if (testResult === 'pending') testBadge = themeColors.warning(t('settings.badge.testing'))
186
+ else if (testResult === 'ok') testBadge = themeColors.successBold(t('settings.badge.testOk'))
187
+ else if (testResult === 'missing_key') testBadge = themeColors.dim(t('settings.badge.missingKey'))
188
+ else if (testResult === 'auth_error') testBadge = themeColors.error(t('settings.badge.authError'))
189
+ else if (testResult === 'rate_limited') testBadge = themeColors.warning(t('settings.badge.rateLimited'))
190
+ else if (testResult === 'no_callable_model') testBadge = chalk.rgb(...getProviderRgb('openrouter'))(t('settings.badge.noCallableModel'))
191
+ else if (testResult === 'fail') testBadge = themeColors.error(t('settings.badge.failed'))
192
192
  // πŸ“– No truncation of rate limits - overlay now uses 100% terminal width.
193
193
  // πŸ“– Paid/credits-required providers get an explicit money marker + parenthesized detail.
194
194
  const billingNote = getProviderBillingNote(pk)
195
- const rateSummary = themeColors.dim(`${meta.rateLimits || 'No limit info'}${billingNote ? ` ${billingNote}` : ''}`)
195
+ const rateSummary = themeColors.dim(`${meta.rateLimits || t('settings.noLimitInfo')}${billingNote ? ` ${billingNote}` : ''}`)
196
196
 
197
197
  const enabledBadge = enabled ? themeColors.successBold('βœ…') : themeColors.errorBold('❌')
198
198
  // πŸ“– Color provider names the same way as in the main table
@@ -211,17 +211,17 @@ export function createOverlayRenderers(state, deps) {
211
211
  const selectedMeta = PROVIDER_METADATA[selectedProviderKey] || {}
212
212
  if (selectedSource && state.settingsCursor < providerKeys.length) {
213
213
  const selectedKey = getApiKey(state.config, selectedProviderKey)
214
- const setupStatus = selectedKey ? themeColors.success('API key detected βœ…') : themeColors.warning('API key missing ⚠')
214
+ const setupStatus = selectedKey ? themeColors.success(t('settings.keyDetected')) : themeColors.warning(t('settings.keyMissing'))
215
215
  // πŸ“– Color the provider name in the setup instructions header
216
216
  const selectedProviderRgb = PROVIDER_COLOR[selectedProviderKey] ?? [105, 190, 245]
217
217
  const selectedProviderLabel = getProviderLabelWithBilling(selectedProviderKey, selectedSource.name || selectedProviderKey)
218
218
  const selectedBillingNote = getProviderBillingNote(selectedProviderKey)
219
219
  const coloredProviderName = chalk.bold.rgb(...selectedProviderRgb)(selectedProviderLabel)
220
- lines.push(` ${themeColors.textBold('Setup Instructions')} β€” ${coloredProviderName}${selectedBillingNote ? ' ' + themeColors.warning(selectedBillingNote) : ''}`)
221
- lines.push(themeColors.dim(` 1) Create a ${selectedMeta.label || selectedSource.name} account: ${selectedMeta.signupUrl || 'signup link missing'}`))
220
+ lines.push(` ${themeColors.textBold(t('settings.setupInstructions'))} β€” ${coloredProviderName}${selectedBillingNote ? ' ' + themeColors.warning(selectedBillingNote) : ''}`)
221
+ lines.push(themeColors.dim(` 1) ${t('settings.createProviderAccount')} (${selectedMeta.label || selectedSource.name}): ${selectedMeta.signupUrl || 'signup link missing'}`))
222
222
  if (selectedBillingNote) lines.push(themeColors.warning(` πŸ’° Paid provider note: ${selectedBillingNote}`))
223
- lines.push(themeColors.dim(` 2) ${selectedMeta.signupHint || 'Generate an API key and paste it with Enter on this row'}`))
224
- lines.push(themeColors.dim(` 3) Press ${themeColors.hotkey('T')} to test your key. Status: ${setupStatus}`))
223
+ lines.push(themeColors.dim(` 2) ${selectedMeta.signupHint || t('settings.generateApiKeyHint')}`))
224
+ lines.push(themeColors.dim(` 3) ${t('settings.pressToTestKey', { key: themeColors.hotkey('T') })} ${setupStatus}`))
225
225
  if (selectedProviderKey === 'cloudflare') {
226
226
  // πŸ“– Account id resolution has fallbacks since issue #181: env var,
227
227
  // πŸ“– stored config setting, or zero-setup auto-discovery from the API key.
@@ -235,7 +235,7 @@ export function createOverlayRenderers(state, deps) {
235
235
  const testDetail = state.settingsTestDetails?.[selectedProviderKey]
236
236
  if (testDetail) {
237
237
  lines.push('')
238
- lines.push(themeColors.errorBold(' Test Diagnostics'))
238
+ lines.push(themeColors.errorBold(` ${t('settings.testDiagnostics')}`))
239
239
  for (const detailLine of wrapPlainText(testDetail)) {
240
240
  lines.push(themeColors.error(` ${detailLine}`))
241
241
  }
@@ -252,38 +252,44 @@ export function createOverlayRenderers(state, deps) {
252
252
  const updateState = state.settingsUpdateState
253
253
  const latestFound = state.settingsUpdateLatestVersion
254
254
  const updateActionLabel = updateState === 'available' && latestFound
255
- ? `Install update (v${latestFound})`
256
- : 'Check for updates manually'
257
- let updateStatus = themeColors.dim('Press Enter or U to check npm registry')
258
- if (updateState === 'checking') updateStatus = themeColors.warning('Checking npm registry…')
259
- if (updateState === 'available' && latestFound) updateStatus = themeColors.successBold(`Update available: v${latestFound} (Enter to install)`)
260
- if (updateState === 'up-to-date') updateStatus = themeColors.success('Already on latest version')
261
- if (updateState === 'error') updateStatus = themeColors.error('Check failed (press U to retry)')
262
- if (updateState === 'installing') updateStatus = themeColors.info('Installing update…')
263
- const updateRow = `${bullet(updateCursor)}${themeColors.textBold(updateActionLabel).padEnd(44)} ${updateStatus}`
255
+ ? t('settings.installUpdate', { version: latestFound })
256
+ : t('settings.checkUpdatesManually')
257
+ let updateStatus = themeColors.dim(t('settings.pressToCheckUpdates'))
258
+ if (updateState === 'checking') updateStatus = themeColors.warning(t('update.checking'))
259
+ if (updateState === 'available' && latestFound) updateStatus = themeColors.successBold(t('settings.updateAvailable', { version: latestFound }))
260
+ if (updateState === 'up-to-date') updateStatus = themeColors.success(t('settings.upToDate'))
261
+ if (updateState === 'error') updateStatus = themeColors.error(t('settings.updateCheckFailed'))
262
+ if (updateState === 'installing') updateStatus = themeColors.info(t('settings.installingUpdate'))
263
+ const updateRow = `${bullet(updateCursor)}${padEndDisplay(themeColors.textBold(updateActionLabel), 44)} ${updateStatus}`
264
264
  cursorLineByRow[updateRowIdx] = lines.length
265
265
  lines.push(updateCursor ? themeColors.bgCursor(updateRow) : updateRow)
266
266
  const themeStatus = getThemeStatusLabel(activeThemeSetting())
267
267
  const themeStatusColor = themeStatus.includes('Dark') ? themeColors.warningBold : themeColors.info
268
- const themeRow = `${bullet(state.settingsCursor === themeRowIdx)}${themeColors.textBold('Global Theme').padEnd(44)} ${themeStatusColor(themeStatus)}`
268
+ const themeRow = `${bullet(state.settingsCursor === themeRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.globalTheme')), 44)} ${themeStatusColor(themeStatus)}`
269
269
  cursorLineByRow[themeRowIdx] = lines.length
270
270
  lines.push(state.settingsCursor === themeRowIdx ? themeColors.bgCursor(themeRow) : themeRow)
271
271
 
272
+ const activeLanguage = state.config.settings?.language === 'zh-CN' ? 'zh-CN' : 'en'
273
+ const languageLabel = activeLanguage === 'zh-CN' ? t('common.language.simplifiedChinese') : t('common.language.english')
274
+ const languageRow = `${bullet(state.settingsCursor === languageRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.language')), 44)} ${themeColors.info(languageLabel)}`
275
+ cursorLineByRow[languageRowIdx] = lines.length
276
+ lines.push(state.settingsCursor === languageRowIdx ? themeColors.bgCursorSettingsList(languageRow) : languageRow)
277
+
272
278
  // πŸ“– Favorites mode row mirrors Y-key behavior from the main table.
273
279
  const favoritesModeEnabled = state.favoritesPinnedAndSticky === true
274
280
  const favoritesModeStatus = favoritesModeEnabled
275
- ? themeColors.warningBold('Pinned + always visible')
276
- : themeColors.info('Normal rows (filter/sort)')
277
- const favoritesModeRow = `${bullet(state.settingsCursor === favoritesModeRowIdx)}${themeColors.textBold('Favorites Display Mode').padEnd(44)} ${favoritesModeStatus}`
281
+ ? themeColors.warningBold(t('settings.favoritesPinned'))
282
+ : themeColors.info(t('settings.favoritesNormal'))
283
+ const favoritesModeRow = `${bullet(state.settingsCursor === favoritesModeRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.favoritesMode')), 44)} ${favoritesModeStatus}`
278
284
  cursorLineByRow[favoritesModeRowIdx] = lines.length
279
285
  lines.push(state.settingsCursor === favoritesModeRowIdx ? themeColors.bgCursorSettingsList(favoritesModeRow) : favoritesModeRow)
280
286
 
281
287
  // πŸ“– Startup AI Speed Scan row controls the opt-in Ctrl+U auto-run at launch.
282
288
  const startupAiSpeedScanEnabled = state.config.settings?.runAiSpeedTestOnStartup === true
283
289
  const startupAiSpeedScanStatus = startupAiSpeedScanEnabled
284
- ? themeColors.successBold('βœ… Enabled β€” runs Ctrl+U after startup')
285
- : themeColors.dim('❌ Disabled β€” manual Ctrl+U only')
286
- const startupAiSpeedScanRow = `${bullet(state.settingsCursor === startupAiSpeedScanRowIdx)}${themeColors.textBold('Startup AI Speed Scan').padEnd(44)} ${startupAiSpeedScanStatus}`
290
+ ? themeColors.successBold(t('settings.startupSpeedTestEnabled'))
291
+ : themeColors.dim(t('settings.startupSpeedTestDisabled'))
292
+ const startupAiSpeedScanRow = `${bullet(state.settingsCursor === startupAiSpeedScanRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.startupSpeedTest')), 44)} ${startupAiSpeedScanStatus}`
287
293
  cursorLineByRow[startupAiSpeedScanRowIdx] = lines.length
288
294
  lines.push(state.settingsCursor === startupAiSpeedScanRowIdx ? themeColors.bgCursorSettingsList(startupAiSpeedScanRow) : startupAiSpeedScanRow)
289
295
 
@@ -292,8 +298,8 @@ export function createOverlayRenderers(state, deps) {
292
298
  const hiddenCount = state.config.hiddenModels instanceof Set ? state.config.hiddenModels.size : 0
293
299
  const autoHideStatus = autoHideEnabled
294
300
  ? themeColors.successBold(`βœ… Enabled (${hiddenCount} hidden)`)
295
- : themeColors.errorBold('❌ Disabled')
296
- const autoHideRow = `${bullet(state.settingsCursor === autoHideBrokenModelsRowIdx)}${themeColors.textBold('Auto-hide Broken Models').padEnd(44)} ${autoHideStatus}`
301
+ : themeColors.errorBold(t('common.disabled'))
302
+ const autoHideRow = `${bullet(state.settingsCursor === autoHideBrokenModelsRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.autoHideBrokenModels')), 44)} ${autoHideStatus}`
297
303
  cursorLineByRow[autoHideBrokenModelsRowIdx] = lines.length
298
304
  lines.push(state.settingsCursor === autoHideBrokenModelsRowIdx ? themeColors.bgCursorSettingsList(autoHideRow) : autoHideRow)
299
305
 
@@ -302,23 +308,23 @@ export function createOverlayRenderers(state, deps) {
302
308
  }
303
309
 
304
310
  // πŸ“– Cleanup row removes stale proxy-era config left behind by older builds.
305
- const cleanupLegacyProxyRow = `${bullet(state.settingsCursor === cleanupLegacyProxyRowIdx)}${themeColors.textBold('Clean Legacy Proxy Config').padEnd(44)} ${themeColors.warning('Enter remove discontinued bridge leftovers')}`
311
+ const cleanupLegacyProxyRow = `${bullet(state.settingsCursor === cleanupLegacyProxyRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.cleanupLegacy')), 44)} ${themeColors.warning(t('settings.pressEnterCleanup'))}`
306
312
  cursorLineByRow[cleanupLegacyProxyRowIdx] = lines.length
307
313
  lines.push(state.settingsCursor === cleanupLegacyProxyRowIdx ? themeColors.bgCursorLegacy(cleanupLegacyProxyRow) : cleanupLegacyProxyRow)
308
314
 
309
315
  // πŸ“– Changelog viewer row
310
- const changelogViewRow = `${bullet(state.settingsCursor === changelogViewRowIdx)}${themeColors.textBold('View Changelog').padEnd(44)} ${themeColors.dim('Enter browse version history')}`
316
+ const changelogViewRow = `${bullet(state.settingsCursor === changelogViewRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.viewChangelog')), 44)} ${themeColors.dim(t('settings.pressEnterChangelog'))}`
311
317
  cursorLineByRow[changelogViewRowIdx] = lines.length
312
318
  lines.push(state.settingsCursor === changelogViewRowIdx ? themeColors.bgCursorSettingsList(changelogViewRow) : changelogViewRow)
313
319
 
314
320
  // πŸ“– Shell env toggle β€” expose API keys as shell environment variables
315
321
  const shellEnvSetting = state.config.settings?.shellEnvEnabled
316
322
  const shellEnvStatus = shellEnvSetting === true
317
- ? themeColors.successBold('βœ… Enabled β€” keys available in shell')
323
+ ? themeColors.successBold(t('settings.shellEnvEnabled'))
318
324
  : shellEnvSetting === false
319
- ? themeColors.dim('❌ Disabled')
320
- : themeColors.warning('πŸ”˜ Not configured β€” Enter to set up')
321
- const shellEnvRow = `${bullet(state.settingsCursor === shellEnvRowIdx)}${themeColors.textBold('Shell Env Export').padEnd(44)} ${shellEnvStatus}`
325
+ ? themeColors.dim(t('common.disabled'))
326
+ : themeColors.warning(t('settings.shellEnvNotConfigured'))
327
+ const shellEnvRow = `${bullet(state.settingsCursor === shellEnvRowIdx)}${padEndDisplay(themeColors.textBold(t('settings.shellEnv')), 44)} ${shellEnvStatus}`
322
328
  cursorLineByRow[shellEnvRowIdx] = lines.length
323
329
  lines.push(state.settingsCursor === shellEnvRowIdx ? themeColors.bgCursorSettingsList(shellEnvRow) : shellEnvRow)
324
330
 
@@ -326,9 +332,9 @@ export function createOverlayRenderers(state, deps) {
326
332
 
327
333
  lines.push('')
328
334
  if (state.settingsEditMode) {
329
- lines.push(themeColors.dim(' Type API key β€’ Enter Save β€’ Esc Cancel'))
335
+ lines.push(themeColors.dim(` ${t('settings.typeApiKey')} β€’ Enter ${t('common.save')} β€’ Esc ${t('common.cancel')}`))
330
336
  } else {
331
- lines.push(themeColors.dim(' ↑↓ Navigate β€’ Enter Edit/Run/Cycle β€’ + Add key β€’ - Remove key β€’ Space Toggle/Cycle β€’ T Test key β€’ U Updates β€’ G Theme β€’ Y Favorites β€’ Esc Close'))
337
+ lines.push(themeColors.dim(` ↑↓ ${t('settings.navigate')} β€’ Enter ${t('settings.editRunCycle')} β€’ + ${t('settings.addKey')} β€’ - ${t('settings.removeKey')} β€’ Space ${t('settings.toggleCycle')} β€’ T ${t('settings.testKey')} β€’ U ${t('settings.updates')} β€’ G ${t('settings.theme')} β€’ Y ${t('settings.favorites')} β€’ Esc ${t('common.close')}`))
332
338
  }
333
339
  // πŸ“– Show sync/restore status message if set
334
340
  if (state.settingsSyncStatus) {
@@ -390,13 +396,13 @@ export function createOverlayRenderers(state, deps) {
390
396
  const scopeChoices = [
391
397
  {
392
398
  key: 'all',
393
- label: 'Install all models',
394
- hint: 'Recommended β€” FCM will refresh this provider catalog automatically later.',
399
+ label: t('install.scope.all.label'),
400
+ hint: t('install.scope.all.hint'),
395
401
  },
396
402
  {
397
403
  key: 'selected',
398
- label: 'Install selected models only',
399
- hint: 'Choose a smaller curated subset for a cleaner model picker.',
404
+ label: t('install.scope.selected.label'),
405
+ hint: t('install.scope.selected.hint'),
400
406
  },
401
407
  ]
402
408
  const selectedProviderLabel = state.installEndpointsProviderKey === 'fcm_router'
@@ -414,40 +420,40 @@ export function createOverlayRenderers(state, deps) {
414
420
  })()
415
421
  : 'β€”'
416
422
 
417
- const selectedConnectionLabel = 'Direct Provider'
423
+ const selectedConnectionLabel = t('install.directProvider')
418
424
 
419
425
  lines.push('')
420
426
  // πŸ“– Branding header
421
427
  lines.push(` ${themeColors.accent('πŸš€')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
422
- lines.push(` ${themeColors.textBold('πŸ”Œ Install Endpoints')}`)
428
+ lines.push(` ${themeColors.textBold(`πŸ”Œ ${t('install.title')}`)}`)
423
429
  lines.push('')
424
- lines.push(themeColors.dim(' β€” install provider catalogs into supported coding tools'))
430
+ lines.push(themeColors.dim(` β€” ${t('install.tuiSubtitle')}`))
425
431
  if (state.installEndpointsErrorMsg) {
426
432
  lines.push(` ${themeColors.warning(state.installEndpointsErrorMsg)}`)
427
433
  }
428
434
  lines.push('')
429
435
 
430
436
  if (state.installEndpointsPhase === 'providers') {
431
- lines.push(` ${themeColors.textBold(`Step 1/${totalSteps}`)} ${themeColors.info('Choose a configured provider')}`)
437
+ lines.push(` ${themeColors.textBold(t('common.stepNumber', { step: 1, total: totalSteps }))} ${themeColors.info(t('install.chooseConfiguredProvider'))}`)
432
438
  lines.push('')
433
439
 
434
440
  if (providerChoices.length === 0) {
435
- lines.push(themeColors.dim(' No configured providers can be installed directly right now.'))
436
- lines.push(themeColors.dim(' Add an API key in Settings (`P`) first, then reopen this screen.'))
441
+ lines.push(themeColors.dim(t('install.noProvidersConfigured')))
442
+ lines.push(themeColors.dim(t('install.keyRequired')))
437
443
  } else {
438
444
  providerChoices.forEach((provider, idx) => {
439
445
  const isCursor = idx === state.installEndpointsCursor
440
- const row = `${bullet(isCursor)}${themeColors.textBold(provider.label.padEnd(24))} ${themeColors.dim(`${provider.modelCount} models`)}`
446
+ const row = `${bullet(isCursor)}${themeColors.textBold(provider.label.padEnd(24))} ${themeColors.dim(t('install.modelsCount', { count: provider.modelCount }))}`
441
447
  cursorLineByRow[idx] = lines.length
442
448
  lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
443
449
  })
444
450
  }
445
451
 
446
452
  lines.push('')
447
- lines.push(themeColors.dim(' ↑↓ Navigate β€’ Enter Choose provider β€’ Esc Close'))
453
+ lines.push(themeColors.dim(` ↑↓ ${t('settings.navigate')} β€’ Enter ${t('install.selectProvider')} β€’ Esc ${t('common.close')}`))
448
454
  } else if (state.installEndpointsPhase === 'tools') {
449
- lines.push(` ${themeColors.textBold(`Step 2/${totalSteps}`)} ${themeColors.info('Choose the target tool')}`)
450
- lines.push(themeColors.dim(` Provider: ${selectedProviderLabel}`))
455
+ lines.push(` ${themeColors.textBold(t('common.stepNumber', { step: 2, total: totalSteps }))} ${themeColors.info(t('install.chooseTargetTool'))}`)
456
+ lines.push(themeColors.dim(` ${t('install.provider')}: ${selectedProviderLabel}`))
451
457
  lines.push('')
452
458
 
453
459
  // πŸ“– Use getToolMeta for labels instead of hard-coded ternary chains
@@ -456,20 +462,20 @@ export function createOverlayRenderers(state, deps) {
456
462
  const meta = getToolMeta(toolMode)
457
463
  const label = `${meta.emoji} ${meta.label}`
458
464
  const note = toolMode.startsWith('opencode')
459
- ? themeColors.dim('shared config file')
465
+ ? themeColors.dim(t('install.sharedConfigFile'))
460
466
  : toolMode === 'openhands'
461
- ? themeColors.dim('env file (~/.fcm-*-env)')
462
- : themeColors.dim('managed config install')
467
+ ? themeColors.dim(t('install.envFile'))
468
+ : themeColors.dim(t('install.managedConfig'))
463
469
  const row = `${bullet(isCursor)}${themeColors.textBold(label.padEnd(26))} ${note}`
464
470
  cursorLineByRow[idx] = lines.length
465
471
  lines.push(isCursor ? themeColors.bgCursorInstall(row) : row)
466
472
  })
467
473
 
468
474
  lines.push('')
469
- lines.push(themeColors.dim(' ↑↓ Navigate β€’ Enter Choose tool β€’ Esc Back'))
475
+ lines.push(themeColors.dim(` ↑↓ ${t('settings.navigate')} β€’ Enter ${t('install.selectTool')} β€’ Esc ${t('common.back')}`))
470
476
  } else if (state.installEndpointsPhase === 'scope') {
471
- lines.push(` ${themeColors.textBold(`Step 3/${totalSteps}`)} ${themeColors.info('Choose the install scope')}`)
472
- lines.push(themeColors.dim(` Provider: ${selectedProviderLabel} β€’ Tool: ${selectedToolLabel} β€’ ${selectedConnectionLabel}`))
477
+ lines.push(` ${themeColors.textBold(t('common.stepNumber', { step: 3, total: totalSteps }))} ${themeColors.info(t('install.chooseInstallScope'))}`)
478
+ lines.push(themeColors.dim(` ${t('install.provider')}: ${selectedProviderLabel} β€’ ${t('install.tool')}: ${selectedToolLabel} β€’ ${selectedConnectionLabel}`))
473
479
  lines.push('')
474
480
 
475
481
  scopeChoices.forEach((scope, idx) => {
@@ -481,14 +487,14 @@ export function createOverlayRenderers(state, deps) {
481
487
  lines.push('')
482
488
  })
483
489
 
484
- lines.push(themeColors.dim(' Enter Continue β€’ Esc Back'))
490
+ lines.push(themeColors.dim(` Enter ${t('install.next')} β€’ Esc ${t('common.back')}`))
485
491
  } else if (state.installEndpointsPhase === 'models') {
486
492
  const models = getProviderCatalogModels(state.installEndpointsProviderKey)
487
493
  const selectedCount = state.installEndpointsSelectedModelIds.size
488
494
 
489
- lines.push(` ${themeColors.textBold(`Step 4/${totalSteps}`)} ${themeColors.info('Choose which models to install')}`)
490
- lines.push(themeColors.dim(` Provider: ${selectedProviderLabel} β€’ Tool: ${selectedToolLabel} β€’ ${selectedConnectionLabel}`))
491
- lines.push(themeColors.dim(` Selected: ${selectedCount}/${models.length}`))
495
+ lines.push(` ${themeColors.textBold(t('common.stepNumber', { step: 4, total: totalSteps }))} ${themeColors.info(t('install.chooseModelsToInstall'))}`)
496
+ lines.push(themeColors.dim(` ${t('install.provider')}: ${selectedProviderLabel} β€’ ${t('install.tool')}: ${selectedToolLabel} β€’ ${selectedConnectionLabel}`))
497
+ lines.push(themeColors.dim(` ${t('install.selectedCount', { selected: selectedCount, total: models.length })}`))
492
498
  lines.push('')
493
499
 
494
500
  models.forEach((model, idx) => {
@@ -502,11 +508,11 @@ export function createOverlayRenderers(state, deps) {
502
508
  })
503
509
 
504
510
  lines.push('')
505
- lines.push(themeColors.dim(' ↑↓ Navigate β€’ Space Toggle model β€’ A All/None β€’ Enter Install β€’ Esc Back'))
511
+ lines.push(themeColors.dim(` ↑↓ ${t('settings.navigate')} β€’ Space ${t('install.selectModels')} β€’ A ${t('install.scope.all')} β€’ Enter ${t('install.install')} β€’ Esc ${t('common.back')}`))
506
512
  } else if (state.installEndpointsPhase === 'result') {
507
513
  const result = state.installEndpointsResult
508
514
  const accent = result?.type === 'success' ? themeColors.successBold : themeColors.errorBold
509
- lines.push(` ${themeColors.textBold('Result')} ${accent(result?.title || 'Install result unavailable')}`)
515
+ lines.push(` ${themeColors.textBold(t('install.result'))} ${accent(result?.title || t('install.resultUnavailable'))}`)
510
516
  lines.push('')
511
517
 
512
518
  for (const detail of result?.lines || []) {
@@ -515,11 +521,11 @@ export function createOverlayRenderers(state, deps) {
515
521
 
516
522
  if (result?.type === 'success') {
517
523
  lines.push('')
518
- lines.push(themeColors.dim(' Future FCM launches will refresh this catalog automatically when the provider list evolves.'))
524
+ lines.push(themeColors.dim(` ${t('install.futureRefresh')}`))
519
525
  }
520
526
 
521
527
  lines.push('')
522
- lines.push(themeColors.dim(' Enter or Esc Close'))
528
+ lines.push(themeColors.dim(` Enter / Esc ${t('common.close')}`))
523
529
  }
524
530
 
525
531
  const targetLine = cursorLineByRow[state.installEndpointsCursor] ?? 0
@@ -815,7 +821,7 @@ export function createOverlayRenderers(state, deps) {
815
821
  }
816
822
 
817
823
  if (allResults.length === 0) {
818
- panelLines.push(sty.dim(' No commands found. Try a different search.'))
824
+ panelLines.push(sty.dim(` ${t('palette.noResults')}`))
819
825
  } else {
820
826
  for (let idx = 0; idx < allResults.length; idx++) {
821
827
  const entry = allResults[idx]
@@ -883,7 +889,7 @@ export function createOverlayRenderers(state, deps) {
883
889
  const query = state.commandPaletteQuery || ''
884
890
  const queryWithCursor = query.length > 0
885
891
  ? `${query}${sty.accentBold('▏')}`
886
- : sty.accentBold('▏') + sty.dim(' Search commands…')
892
+ : sty.accentBold('▏') + sty.dim(t('palette.searchPlaceholder'))
887
893
 
888
894
  // πŸ“– Header: title left, Esc hint right, search input, separator. Each piece is
889
895
  // πŸ“– width-budgeted so the header can never push past the panel on tiny screens.
@@ -891,17 +897,17 @@ export function createOverlayRenderers(state, deps) {
891
897
  const titleBudget = Math.max(1, panelInnerWidth - 1 - displayWidth('Esc'))
892
898
  const queryBudget = Math.max(1, panelInnerWidth - 1)
893
899
  const headerLines = [
894
- ` ${padEndDisplay(truncateAnsiWidth(sty.headerBold('⚑️ Command Palette'), titleBudget, { ellipsis: '…' }), titleBudget)} ${escHint}`,
900
+ ` ${padEndDisplay(truncateAnsiWidth(sty.headerBold(`⚑️ ${t('palette.title')}`), titleBudget, { ellipsis: '…' }), titleBudget)} ${escHint}`,
895
901
  ` ${padEndDisplay(truncateAnsiWidth(`> ${queryWithCursor}`, queryBudget, { ellipsis: '…' }), queryBudget)}`,
896
902
  sty.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth - 1))}`),
897
903
  ]
898
904
 
899
905
  const sepLine = sty.dim(` ${'─'.repeat(Math.max(1, panelInnerWidth - 1))}`)
900
906
  const footerLines = caps.compact
901
- ? [sepLine, ` ${sty.dim(truncateAnsiWidth('↡ Select β€’ ↑↓ Navigate β€’ Type search', panelInnerWidth - 1))}`]
907
+ ? [sepLine, ` ${sty.dim(truncateAnsiWidth(`↡ ${t('common.select')} β€’ ↑↓ ${t('settings.navigate')} β€’ ${t('common.search')}`, panelInnerWidth - 1))}`]
902
908
  : [
903
909
  sepLine,
904
- ` ${padEndDisplay(sty.dim('↡ Select β€’ ← β†’ Expand'), panelInnerWidth - 1)}`,
910
+ ` ${padEndDisplay(sty.dim(`↡ ${t('common.select')} β€’ ← β†’ ${t('common.expand')}`), panelInnerWidth - 1)}`,
905
911
  ` ${padEndDisplay(sty.dim('↑↓ Navigate β€’ Type search'), panelInnerWidth - 1)}`,
906
912
  ]
907
913
 
@@ -964,11 +970,11 @@ export function createOverlayRenderers(state, deps) {
964
970
 
965
971
  // πŸ“– Branding header
966
972
  lines.push(` ${themeColors.accent('πŸš€')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
967
- lines.push(` ${heading('❓ Help & Keyboard Shortcuts')}`)
973
+ lines.push(` ${heading(`❓ ${t('help.title')} Β· ${t('help.shortcuts')}`)}`)
968
974
  lines.push(` ${themeColors.successBold('πŸ”‘ Yellow = active key')}`)
969
975
  lines.push('')
970
976
  lines.push(` ${hint('β€” ↑↓ / PgUp / PgDn / Home / End scroll β€’ K or ')}${themeColors.successBold('Esc close')}`)
971
- lines.push(` ${heading('Columns')}`)
977
+ lines.push(` ${heading(t('help.columns'))}`)
972
978
  lines.push('')
973
979
  lines.push(` ${label('Rank')} SWE-bench rank (1 = best coding score) ${hint('Sort:')} ${key('R')}`)
974
980
  lines.push(` ${hint('Quick glance at which model is objectively the best coder right now.')}`)
@@ -1012,13 +1018,13 @@ export function createOverlayRenderers(state, deps) {
1012
1018
 
1013
1019
 
1014
1020
  lines.push('')
1015
- lines.push(` ${heading('Main TUI')}`)
1016
- lines.push(` ${heading('Navigation')}`)
1021
+ lines.push(` ${heading(t('help.mainTui'))}`)
1022
+ lines.push(` ${heading(t('help.navigation'))}`)
1017
1023
  lines.push(` ${key('↑↓ / J/K')} Navigate rows ${hint('(J/K = vim-style scroll)')}`)
1018
1024
  lines.push(` ${key('Enter')} Select model and launch`)
1019
1025
  lines.push(` ${hint('If the active CLI is missing, FCM offers a one-click install prompt first.')}`)
1020
1026
  lines.push('')
1021
- lines.push(` ${heading('Controls')}`)
1027
+ lines.push(` ${heading(t('help.controls'))}`)
1022
1028
  lines.push(` ${key('W')} Toggle ping mode ${hint('(speed 2s β†’ normal 10s β†’ slow 30s β†’ forced 4s)')}`)
1023
1029
  lines.push(` ${key('Ctrl+P')} Open ⚑️ command palette ${hint('(search and run actions quickly)')}`)
1024
1030
  lines.push(` ${key('Ctrl+A')} AI Speed Test ${hint('(benchmark selected model β†’ time + TPS)')}`)
@@ -1044,7 +1050,7 @@ export function createOverlayRenderers(state, deps) {
1044
1050
  lines.push(` ${key('I')} / ${key('Esc')} Show/hide this help`)
1045
1051
  lines.push(` ${key('Ctrl+C')} Exit`)
1046
1052
  lines.push('')
1047
- lines.push(` ${heading('Settings (P)')}`)
1053
+ lines.push(` ${heading(`${t('settings.title')} (P)`)}`)
1048
1054
  lines.push(` ${key('↑↓')} Navigate rows`)
1049
1055
  lines.push(` ${key('PgUp/PgDn')} Jump by page`)
1050
1056
  lines.push(` ${key('Home/End')} Jump first/last row`)
@@ -1077,27 +1083,27 @@ export function createOverlayRenderers(state, deps) {
1077
1083
  // πŸ“– Branding header
1078
1084
  lines.push('')
1079
1085
  lines.push(` ${themeColors.accent('πŸš€')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
1080
- lines.push(` ${themeColors.textBold('🎯 Smart Recommend')}`)
1086
+ lines.push(` ${themeColors.textBold(`🎯 ${t('recommend.title')}`)}`)
1081
1087
  lines.push('')
1082
- lines.push(themeColors.dim(' β€” find the best model for your task'))
1088
+ lines.push(themeColors.dim(` β€” ${t('recommend.subtitle')}`))
1083
1089
  lines.push('')
1084
1090
 
1085
1091
  if (state.recommendPhase === 'questionnaire') {
1086
1092
  // πŸ“– Question definitions β€” each has a title, options array, and answer key
1087
1093
  const questions = [
1088
1094
  {
1089
- title: 'What are you working on?',
1090
- options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: val.label })),
1095
+ title: t('recommend.questionTask'),
1096
+ options: Object.entries(TASK_TYPES).map(([key, val]) => ({ key, label: t(`recommend.taskType.${key}`) || val.label })),
1091
1097
  answerKey: 'taskType',
1092
1098
  },
1093
1099
  {
1094
- title: 'What matters most?',
1095
- options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: val.label })),
1100
+ title: t('recommend.questionPriority'),
1101
+ options: Object.entries(PRIORITY_TYPES).map(([key, val]) => ({ key, label: t(`recommend.priority.${key}`) || val.label })),
1096
1102
  answerKey: 'priority',
1097
1103
  },
1098
1104
  {
1099
- title: 'How big is your context?',
1100
- options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: val.label })),
1105
+ title: t('recommend.questionContext'),
1106
+ options: Object.entries(CONTEXT_BUDGETS).map(([key, val]) => ({ key, label: t(`recommend.contextBudget.${key}`) || val.label })),
1101
1107
  answerKey: 'contextBudget',
1102
1108
  },
1103
1109
  ]
@@ -1120,7 +1126,7 @@ export function createOverlayRenderers(state, deps) {
1120
1126
  lines.push('')
1121
1127
  }
1122
1128
 
1123
- lines.push(` ${themeColors.textBold(`Question ${qNum}/${qTotal}:`)} ${themeColors.info(q.title)}`)
1129
+ lines.push(` ${themeColors.textBold(t('recommend.questionProgress', { step: qNum, total: qTotal }))}: ${themeColors.info(q.title)}`)
1124
1130
  lines.push('')
1125
1131
 
1126
1132
  for (let i = 0; i < q.options.length; i++) {
@@ -1145,7 +1151,7 @@ export function createOverlayRenderers(state, deps) {
1145
1151
  const empty = barWidth - filled
1146
1152
  const bar = themeColors.successBold('β–ˆ'.repeat(filled)) + themeColors.dim('β–‘'.repeat(empty))
1147
1153
 
1148
- lines.push(` ${themeColors.textBold('Analyzing models...')}`)
1154
+ lines.push(` ${themeColors.textBold(t('recommend.analyzing'))}`)
1149
1155
  lines.push('')
1150
1156
  lines.push(` ${bar} ${themeColors.textBold(String(pct) + '%')}`)
1151
1157
  lines.push('')
@@ -1172,9 +1178,9 @@ export function createOverlayRenderers(state, deps) {
1172
1178
  lines.push('')
1173
1179
 
1174
1180
  if (state.recommendResults.length === 0) {
1175
- lines.push(` ${themeColors.warning('No models could be scored. Try different criteria or wait for more pings.')}`)
1181
+ lines.push(` ${themeColors.warning(t('recommend.noResults'))}`)
1176
1182
  } else {
1177
- lines.push(` ${themeColors.textBold('Top Recommendations:')}`)
1183
+ lines.push(` ${themeColors.textBold(`${t('recommend.topPicks')}:`)}`)
1178
1184
  lines.push('')
1179
1185
 
1180
1186
  for (let i = 0; i < state.recommendResults.length; i++) {
@@ -1367,8 +1373,8 @@ export function createOverlayRenderers(state, deps) {
1367
1373
  let displayText = item.replace(/\*\*([^*]+)\*\*/g, '$1').replace(/`([^`]+)`/g, '$1')
1368
1374
  // πŸ“– Wrap long lines
1369
1375
  const maxWidth = state.terminalCols - 16
1370
- if (displayText.length > maxWidth) {
1371
- displayText = displayText.substring(0, maxWidth - 3) + '…'
1376
+ if (displayWidth(displayText) > maxWidth) {
1377
+ displayText = truncateAnsiWidth(displayText, maxWidth, { ellipsis: '…' })
1372
1378
  }
1373
1379
  lines.push(` β€’ ${displayText}`)
1374
1380
  }
@@ -213,12 +213,13 @@ export function truncateAnsiWidth(input, maxWidth, { ellipsis = '…' } = {}) {
213
213
  // πŸ“– Applies bgColor to each line and pads to terminalCols for full-width panel look.
214
214
  // πŸ“– If terminalCols is not provided, falls back to OVERLAY_PANEL_WIDTH for compatibility.
215
215
  export function tintOverlayLines(lines, bgColor, terminalCols = null) {
216
- const panelWidth = terminalCols || OVERLAY_PANEL_WIDTH
216
+ const panelWidth = Math.max(1, terminalCols || OVERLAY_PANEL_WIDTH)
217
217
  return lines.map((line) => {
218
218
  const text = String(line)
219
- const visibleWidth = displayWidth(text)
219
+ const fitted = truncateAnsiWidth(text, panelWidth, { ellipsis: '' })
220
+ const visibleWidth = displayWidth(fitted)
220
221
  const padding = ' '.repeat(Math.max(0, panelWidth - visibleWidth))
221
- return bgColor(text + padding)
222
+ return bgColor(fitted + padding)
222
223
  })
223
224
  }
224
225