free-coding-models 0.3.22 → 0.3.24

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.
package/src/config.js CHANGED
@@ -209,6 +209,7 @@ function normalizeSettingsSection(settings) {
209
209
  return {
210
210
  ...safeSettings,
211
211
  hideUnconfiguredModels: typeof safeSettings.hideUnconfiguredModels === 'boolean' ? safeSettings.hideUnconfiguredModels : true,
212
+ favoritesPinnedAndSticky: typeof safeSettings.favoritesPinnedAndSticky === 'boolean' ? safeSettings.favoritesPinnedAndSticky : false,
212
213
  theme: ['dark', 'light', 'auto'].includes(safeSettings.theme) ? safeSettings.theme : 'auto',
213
214
  }
214
215
  }
@@ -831,7 +832,7 @@ export function isProviderEnabled(config, providerKey) {
831
832
  /**
832
833
  * 📖 _emptyProfileSettings: Default TUI settings.
833
834
  *
834
- * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, preferredToolMode: string }}
835
+ * @returns {{ tierFilter: string|null, sortColumn: string, sortAsc: boolean, pingInterval: number, hideUnconfiguredModels: boolean, favoritesPinnedAndSticky: boolean, preferredToolMode: string }}
835
836
  */
836
837
  export function _emptyProfileSettings() {
837
838
  return {
@@ -840,6 +841,7 @@ export function _emptyProfileSettings() {
840
841
  sortAsc: true, // 📖 true = ascending (fastest first for latency)
841
842
  pingInterval: 10000, // 📖 default ms between pings in the steady "normal" mode
842
843
  hideUnconfiguredModels: true, // 📖 true = default to providers that are actually configured
844
+ favoritesPinnedAndSticky: false, // 📖 default mode keeps favorites as normal starred rows; press Y to pin+stick them.
843
845
  preferredToolMode: 'opencode', // 📖 remember the last Z-selected launcher across app restarts
844
846
  theme: 'auto', // 📖 'auto' follows the terminal/OS theme, override with 'dark' or 'light' if needed
845
847
  }
@@ -848,7 +850,7 @@ export function _emptyProfileSettings() {
848
850
  /**
849
851
  * 📖 normalizeEndpointInstalls keeps the endpoint-install tracking list safe to replay.
850
852
  *
851
- * 📖 Each entry represents one managed catalog install performed through the `Y` flow:
853
+ * 📖 Each entry represents one managed catalog install performed through Install Endpoints:
852
854
  * - `providerKey`: FCM provider identifier (`nvidia`, `groq`, ...)
853
855
  * - `toolMode`: canonical tool id (`opencode`, `openclaw`, `crush`, `goose`)
854
856
  * - `scope`: `all` or `selected`
@@ -3,7 +3,7 @@
3
3
  * @description Install and refresh FCM-managed provider catalogs inside external tool configs.
4
4
  *
5
5
  * @details
6
- * 📖 This module powers the `Y` hotkey flow in the TUI.
6
+ * 📖 This module powers the Install Endpoints flow in the TUI.
7
7
  * It lets users pick one configured provider, choose a target tool, then install either:
8
8
  * - the full provider catalog (`all` models), or
9
9
  * - a curated subset of specific models (`selected`)
@@ -48,7 +48,8 @@ import { getApiKey, saveConfig } from './config.js'
48
48
  import { ENV_VAR_NAMES, PROVIDER_METADATA } from './provider-metadata.js'
49
49
  import { getToolMeta } from './tool-metadata.js'
50
50
 
51
- const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai'])
51
+ // 📖 CLI-only providers (rovo, gemini) and Zen-only (opencode-zen) cannot be installed into other tools.
52
+ const DIRECT_INSTALL_UNSUPPORTED_PROVIDERS = new Set(['replicate', 'zai', 'rovo', 'gemini', 'opencode-zen'])
52
53
  // 📖 Install Endpoints only lists tools whose persisted config shape is actually supported here.
53
54
  // 📖 Claude Code, Codex, and Gemini stay out while their dedicated bridges are being rebuilt.
54
55
  const INSTALL_TARGET_MODES = ['opencode', 'opencode-desktop', 'openclaw', 'crush', 'goose', 'pi', 'aider', 'qwen', 'openhands', 'amp']
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { loadChangelog } from './changelog-loader.js'
31
+ import { getToolMeta, isModelCompatibleWithTool, getCompatibleTools, findSimilarCompatibleModels } from './tool-metadata.js'
31
32
  import { loadConfig, replaceConfigContents } from './config.js'
32
33
  import { cleanupLegacyProxyArtifacts } from './legacy-proxy-cleanup.js'
33
34
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
@@ -247,9 +248,61 @@ export function createKeyHandler(ctx) {
247
248
  }
248
249
  console.log()
249
250
 
250
- // 📖 OpenClaw manages API keys inside its own config file. All other tools
251
- // 📖 still need a provider key to be useful, so keep the existing warning.
252
- if (state.mode !== 'openclaw') {
251
+ // 📖 CLI-only tool compatibility checks:
252
+ // 📖 Case A: Active tool mode is CLI-only (rovo/gemini) but selected model doesn't belong to it
253
+ // 📖 Case B: Selected model belongs to a CLI-only provider but active mode is something else
254
+ // 📖 Case C: Selected model is from opencode-zen but active mode is not opencode/opencode-desktop
255
+ const activeMeta = getToolMeta(state.mode)
256
+ const isActiveModeCliOnly = activeMeta.cliOnly === true
257
+ const isModelFromCliOnly = selected.providerKey === 'rovo' || selected.providerKey === 'gemini'
258
+ const isModelFromZen = selected.providerKey === 'opencode-zen'
259
+ const modelBelongsToActiveMode = selected.providerKey === state.mode
260
+
261
+ // 📖 Case A: User is in Rovo/Gemini mode but selected a model from a different provider
262
+ if (isActiveModeCliOnly && !modelBelongsToActiveMode) {
263
+ const availableModels = MODELS.filter(m => m[5] === state.mode)
264
+ console.log(chalk.yellow(` ⚠ ${activeMeta.label} can only launch its own models.`))
265
+ console.log(chalk.yellow(` "${selected.label}" is not a ${activeMeta.label} model.`))
266
+ console.log()
267
+ if (availableModels.length > 0) {
268
+ console.log(chalk.cyan(` Available ${activeMeta.label} models:`))
269
+ for (const m of availableModels) {
270
+ console.log(chalk.white(` • ${m[1]} (${m[2]} tier, ${m[3]} SWE, ${m[4]} ctx)`))
271
+ }
272
+ console.log()
273
+ }
274
+ console.log(chalk.dim(` Switch to another tool mode with Z, or select a ${activeMeta.label} model.`))
275
+ console.log()
276
+ process.exit(0)
277
+ }
278
+
279
+ // 📖 Case B: Selected model is from a CLI-only provider but active mode is different
280
+ if (isModelFromCliOnly && !modelBelongsToActiveMode) {
281
+ const modelMeta = getToolMeta(selected.providerKey)
282
+ console.log(chalk.yellow(` ⚠ ${selected.label} is a ${modelMeta.label}-exclusive model.`))
283
+ console.log(chalk.yellow(` Your current tool is: ${activeMeta.label}`))
284
+ console.log()
285
+ console.log(chalk.cyan(` Switching to ${modelMeta.label} and launching...`))
286
+ setToolMode(selected.providerKey)
287
+ console.log(chalk.green(` ✓ Switched to ${modelMeta.label}`))
288
+ console.log()
289
+ }
290
+
291
+ // 📖 Case C: Zen model selected but active mode is not OpenCode CLI / OpenCode Desktop
292
+ // 📖 Auto-switch to OpenCode CLI since Zen models only run on OpenCode
293
+ if (isModelFromZen && state.mode !== 'opencode' && state.mode !== 'opencode-desktop') {
294
+ console.log(chalk.yellow(` ⚠ ${selected.label} is an OpenCode Zen model.`))
295
+ console.log(chalk.yellow(` Zen models only run on OpenCode CLI or OpenCode Desktop.`))
296
+ console.log(chalk.yellow(` Your current tool is: ${activeMeta.label}`))
297
+ console.log()
298
+ console.log(chalk.cyan(` Switching to OpenCode CLI and launching...`))
299
+ setToolMode('opencode')
300
+ console.log(chalk.green(` ✓ Switched to OpenCode CLI`))
301
+ console.log()
302
+ }
303
+
304
+ // 📖 OpenClaw, CLI-only tools, and Zen models manage auth differently — skip API key warning for them.
305
+ if (state.mode !== 'openclaw' && !isModelFromCliOnly && !isModelFromZen) {
253
306
  const selectedApiKey = getApiKey(state.config, selected.providerKey)
254
307
  if (!selectedApiKey) {
255
308
  console.log(chalk.yellow(` Warning: No API key configured for ${selected.providerKey}.`))
@@ -259,6 +312,41 @@ export function createKeyHandler(ctx) {
259
312
  }
260
313
  }
261
314
 
315
+ // 📖 CLI-only tool auto-install check — verify the CLI binary is available before launch.
316
+ const toolModeForProvider = selected.providerKey
317
+ if (isModelFromCliOnly && !isToolInstalled(toolModeForProvider)) {
318
+ const installPlan = getToolInstallPlan(toolModeForProvider)
319
+ if (installPlan.supported) {
320
+ console.log()
321
+ console.log(chalk.yellow(` ⚠ ${getToolMeta(toolModeForProvider).label} is not installed.`))
322
+ console.log(chalk.dim(` ${installPlan.summary}`))
323
+ if (installPlan.note) console.log(chalk.dim(` Note: ${installPlan.note}`))
324
+ console.log()
325
+ console.log(chalk.cyan(` 📦 Auto-installing ${getToolMeta(toolModeForProvider).label}...`))
326
+ console.log()
327
+
328
+ const installResult = await installToolWithPlan(installPlan)
329
+ if (!installResult.ok) {
330
+ console.log(chalk.red(` X Tool installation failed with exit code ${installResult.exitCode}.`))
331
+ if (installPlan.docsUrl) console.log(chalk.dim(` Docs: ${installPlan.docsUrl}`))
332
+ console.log()
333
+ process.exit(installResult.exitCode || 1)
334
+ }
335
+
336
+ // 📖 Verify tool is now installed
337
+ if (!isToolInstalled(toolModeForProvider)) {
338
+ console.log(chalk.yellow(' ⚠ The installer finished, but the tool is still not reachable from this terminal session.'))
339
+ console.log(chalk.dim(' Restart your shell or add the tool bin directory to PATH, then retry the launch.'))
340
+ if (installPlan.docsUrl) console.log(chalk.dim(` Docs: ${installPlan.docsUrl}`))
341
+ console.log()
342
+ process.exit(1)
343
+ }
344
+
345
+ console.log(chalk.green(' ✓ Tool installed successfully. Continuing with the selected model...'))
346
+ console.log()
347
+ }
348
+ }
349
+
262
350
  let exitCode = 0
263
351
  if (state.mode === 'openclaw') {
264
352
  exitCode = await startOpenClaw(userSelected, state.config, { launchCli: true })
@@ -519,7 +607,9 @@ export function createKeyHandler(ctx) {
519
607
  // 📖 Shared table refresh helper so command-palette and hotkeys keep identical behavior.
520
608
  function refreshVisibleSorted({ resetCursor = true } = {}) {
521
609
  const visible = state.results.filter(r => !r.hidden)
522
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
610
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
611
+ pinFavorites: state.favoritesPinnedAndSticky,
612
+ })
523
613
  if (resetCursor) {
524
614
  state.cursor = 0
525
615
  state.scrollOffset = 0
@@ -600,12 +690,48 @@ export function createKeyHandler(ctx) {
600
690
  const modeOrder = getToolModeOrder()
601
691
  const currentIndex = modeOrder.indexOf(state.mode)
602
692
  const nextIndex = (currentIndex + 1) % modeOrder.length
603
- state.mode = modeOrder[nextIndex]
693
+ setToolMode(modeOrder[nextIndex])
694
+ }
695
+
696
+ // 📖 Keep tool-mode changes centralized so keyboard shortcuts and command palette
697
+ // 📖 both persist to config exactly the same way.
698
+ function setToolMode(nextMode) {
699
+ if (!getToolModeOrder().includes(nextMode)) return
700
+ state.mode = nextMode
604
701
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
605
702
  state.config.settings.preferredToolMode = state.mode
606
703
  saveConfig(state.config)
607
704
  }
608
705
 
706
+ // 📖 Favorites display mode:
707
+ // 📖 - true => favorites stay pinned + always visible (legacy behavior)
708
+ // 📖 - false => favorites are just starred rows and obey normal sort/filter rules
709
+ function setFavoritesDisplayMode(nextPinned, { preserveSelection = true } = {}) {
710
+ const normalizedNextPinned = nextPinned !== false
711
+ if (state.favoritesPinnedAndSticky === normalizedNextPinned) return
712
+
713
+ const selected = preserveSelection ? state.visibleSorted[state.cursor] : null
714
+ const selectedKey = selected ? toFavoriteKey(selected.providerKey, selected.modelId) : null
715
+
716
+ state.favoritesPinnedAndSticky = normalizedNextPinned
717
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
718
+ state.config.settings.favoritesPinnedAndSticky = state.favoritesPinnedAndSticky
719
+ saveConfig(state.config)
720
+
721
+ applyTierFilter()
722
+ refreshVisibleSorted({ resetCursor: false })
723
+
724
+ if (selectedKey) {
725
+ const selectedIdx = state.visibleSorted.findIndex((row) => toFavoriteKey(row.providerKey, row.modelId) === selectedKey)
726
+ if (selectedIdx >= 0) state.cursor = selectedIdx
727
+ adjustScrollOffset(state)
728
+ }
729
+ }
730
+
731
+ function toggleFavoritesDisplayMode() {
732
+ setFavoritesDisplayMode(!state.favoritesPinnedAndSticky)
733
+ }
734
+
609
735
  function resetViewSettings() {
610
736
  state.tierFilterMode = 0
611
737
  state.originFilterMode = 0
@@ -631,7 +757,7 @@ export function createKeyHandler(ctx) {
631
757
  applyTierFilter()
632
758
  refreshVisibleSorted({ resetCursor: false })
633
759
 
634
- if (wasFavorite) {
760
+ if (wasFavorite && state.favoritesPinnedAndSticky) {
635
761
  state.cursor = 0
636
762
  state.scrollOffset = 0
637
763
  return
@@ -654,11 +780,28 @@ export function createKeyHandler(ctx) {
654
780
  }
655
781
 
656
782
  function refreshCommandPaletteResults() {
783
+ const query = (state.commandPaletteQuery || '').trim()
657
784
  const tree = buildCommandPaletteTree(state.results || [])
658
- const flat = flattenCommandTree(tree, state.commandPaletteExpandedIds)
659
- state.commandPaletteResults = filterCommandPaletteEntries(flat, state.commandPaletteQuery)
785
+ // 📖 Keep collapsed view clean when query is empty, but search across the
786
+ // 📖 full tree when users type so hidden submenu commands still appear.
787
+ let flat
788
+ if (query.length > 0) {
789
+ const expandedIds = new Set()
790
+ const collectExpandedIds = (nodes) => {
791
+ for (const node of nodes || []) {
792
+ if (Array.isArray(node.children) && node.children.length > 0) {
793
+ expandedIds.add(node.id)
794
+ collectExpandedIds(node.children)
795
+ }
796
+ }
797
+ }
798
+ collectExpandedIds(tree)
799
+ flat = flattenCommandTree(tree, expandedIds)
800
+ } else {
801
+ flat = flattenCommandTree(tree, state.commandPaletteExpandedIds)
802
+ }
803
+ state.commandPaletteResults = filterCommandPaletteEntries(flat, query)
660
804
 
661
- const query = (state.commandPaletteQuery || '').trim()
662
805
  if (query.length > 0) {
663
806
  state.commandPaletteResults.unshift({
664
807
  id: 'filter-custom-text-apply',
@@ -707,6 +850,21 @@ export function createKeyHandler(ctx) {
707
850
  function executeCommandPaletteEntry(entry) {
708
851
  if (!entry?.id) return
709
852
 
853
+ if (entry.id.startsWith('action-set-ping-') && entry.pingMode) {
854
+ setPingMode(entry.pingMode, 'manual')
855
+ return
856
+ }
857
+
858
+ if (entry.id.startsWith('action-set-tool-') && entry.toolMode) {
859
+ setToolMode(entry.toolMode)
860
+ return
861
+ }
862
+
863
+ if (entry.id.startsWith('action-favorites-mode-') && typeof entry.favoritesPinned === 'boolean') {
864
+ setFavoritesDisplayMode(entry.favoritesPinned)
865
+ return
866
+ }
867
+
710
868
  if (entry.id.startsWith('filter-tier-')) {
711
869
  setTierFilterFromCommand(entry.tier ?? null)
712
870
  return
@@ -793,6 +951,7 @@ export function createKeyHandler(ctx) {
793
951
  return
794
952
  }
795
953
  case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
954
+ case 'action-toggle-favorite-mode': return toggleFavoritesDisplayMode()
796
955
  case 'action-reset-view': return resetViewSettings()
797
956
  default:
798
957
  return
@@ -1129,6 +1288,85 @@ export function createKeyHandler(ctx) {
1129
1288
  return
1130
1289
  }
1131
1290
 
1291
+ // 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
1292
+ // 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
1293
+ if (state.incompatibleFallbackOpen) {
1294
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1295
+
1296
+ const tools = state.incompatibleFallbackTools || []
1297
+ const similarModels = state.incompatibleFallbackSimilarModels || []
1298
+ const totalItems = tools.length + similarModels.length
1299
+
1300
+ if (key.name === 'escape') {
1301
+ // 📖 Close the overlay and go back to the main table
1302
+ state.incompatibleFallbackOpen = false
1303
+ state.incompatibleFallbackCursor = 0
1304
+ state.incompatibleFallbackScrollOffset = 0
1305
+ state.incompatibleFallbackModel = null
1306
+ state.incompatibleFallbackTools = []
1307
+ state.incompatibleFallbackSimilarModels = []
1308
+ state.incompatibleFallbackSection = 'tools'
1309
+ return
1310
+ }
1311
+
1312
+ if (key.name === 'up' && totalItems > 0) {
1313
+ state.incompatibleFallbackCursor = state.incompatibleFallbackCursor > 0
1314
+ ? state.incompatibleFallbackCursor - 1
1315
+ : totalItems - 1
1316
+ state.incompatibleFallbackSection = state.incompatibleFallbackCursor < tools.length ? 'tools' : 'models'
1317
+ return
1318
+ }
1319
+
1320
+ if (key.name === 'down' && totalItems > 0) {
1321
+ state.incompatibleFallbackCursor = state.incompatibleFallbackCursor < totalItems - 1
1322
+ ? state.incompatibleFallbackCursor + 1
1323
+ : 0
1324
+ state.incompatibleFallbackSection = state.incompatibleFallbackCursor < tools.length ? 'tools' : 'models'
1325
+ return
1326
+ }
1327
+
1328
+ if (key.name === 'return' && totalItems > 0) {
1329
+ const cursor = state.incompatibleFallbackCursor
1330
+ const fallbackModel = state.incompatibleFallbackModel
1331
+
1332
+ // 📖 Close overlay state first
1333
+ state.incompatibleFallbackOpen = false
1334
+ state.incompatibleFallbackCursor = 0
1335
+ state.incompatibleFallbackScrollOffset = 0
1336
+ state.incompatibleFallbackModel = null
1337
+ state.incompatibleFallbackTools = []
1338
+ state.incompatibleFallbackSimilarModels = []
1339
+ state.incompatibleFallbackSection = 'tools'
1340
+
1341
+ if (cursor < tools.length) {
1342
+ // 📖 Section 1: Switch to the selected compatible tool, then launch the original model
1343
+ const selectedToolKey = tools[cursor]
1344
+ setToolMode(selectedToolKey)
1345
+ // 📖 Find the full result object for the original model to pass to launchSelectedModel
1346
+ const fullModel = state.results.find(
1347
+ r => r.providerKey === fallbackModel.providerKey && r.modelId === fallbackModel.modelId
1348
+ )
1349
+ if (fullModel) {
1350
+ await launchSelectedModel(fullModel)
1351
+ }
1352
+ } else {
1353
+ // 📖 Section 2: Launch the selected similar model instead
1354
+ const modelIdx = cursor - tools.length
1355
+ const selectedSimilar = similarModels[modelIdx]
1356
+ if (selectedSimilar) {
1357
+ const fullModel = state.results.find(
1358
+ r => r.providerKey === selectedSimilar.providerKey && r.modelId === selectedSimilar.modelId
1359
+ )
1360
+ if (fullModel) {
1361
+ await launchSelectedModel(fullModel)
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+
1367
+ return
1368
+ }
1369
+
1132
1370
  // 📖 Feedback overlay: intercept ALL keys while overlay is active.
1133
1371
  // 📖 Enter → send to Discord, Esc → cancel, Backspace → delete char, printable → append to buffer.
1134
1372
  if (state.feedbackOpen) {
@@ -1442,7 +1680,8 @@ export function createKeyHandler(ctx) {
1442
1680
  const providerKeys = Object.keys(sources)
1443
1681
  const updateRowIdx = providerKeys.length
1444
1682
  const themeRowIdx = updateRowIdx + 1
1445
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
1683
+ const favoritesModeRowIdx = themeRowIdx + 1
1684
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
1446
1685
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
1447
1686
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1448
1687
  const maxRowIdx = changelogViewRowIdx
@@ -1523,10 +1762,7 @@ export function createKeyHandler(ctx) {
1523
1762
  setResults(nextResults)
1524
1763
  syncFavoriteFlags(state.results, state.config)
1525
1764
  applyTierFilter()
1526
- const visible = state.results.filter(r => !r.hidden)
1527
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1528
- if (state.cursor >= state.visibleSorted.length) state.cursor = Math.max(0, state.visibleSorted.length - 1)
1529
- adjustScrollOffset(state)
1765
+ refreshVisibleSorted({ resetCursor: false })
1530
1766
  // 📖 Re-ping all models that were 'noauth' (got 401 without key) but now have a key
1531
1767
  // 📖 This makes the TUI react immediately when a user adds an API key in settings
1532
1768
  const pingModel = getPingModel?.()
@@ -1591,6 +1827,11 @@ export function createKeyHandler(ctx) {
1591
1827
  return
1592
1828
  }
1593
1829
 
1830
+ if (state.settingsCursor === favoritesModeRowIdx) {
1831
+ toggleFavoritesDisplayMode()
1832
+ return
1833
+ }
1834
+
1594
1835
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1595
1836
  runLegacyProxyCleanup()
1596
1837
  return
@@ -1629,6 +1870,10 @@ export function createKeyHandler(ctx) {
1629
1870
  cycleGlobalTheme()
1630
1871
  return
1631
1872
  }
1873
+ if (state.settingsCursor === favoritesModeRowIdx) {
1874
+ toggleFavoritesDisplayMode()
1875
+ return
1876
+ }
1632
1877
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1633
1878
 
1634
1879
  // 📖 Toggle enabled/disabled for selected provider
@@ -1644,6 +1889,7 @@ export function createKeyHandler(ctx) {
1644
1889
  if (
1645
1890
  state.settingsCursor === updateRowIdx
1646
1891
  || state.settingsCursor === themeRowIdx
1892
+ || state.settingsCursor === favoritesModeRowIdx
1647
1893
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1648
1894
  || state.settingsCursor === changelogViewRowIdx
1649
1895
  ) return
@@ -1661,6 +1907,12 @@ export function createKeyHandler(ctx) {
1661
1907
  return
1662
1908
  }
1663
1909
 
1910
+ // 📖 Y toggles favorites display mode directly from Settings.
1911
+ if (key.name === 'y') {
1912
+ toggleFavoritesDisplayMode()
1913
+ return
1914
+ }
1915
+
1664
1916
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1665
1917
 
1666
1918
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -1707,9 +1959,20 @@ export function createKeyHandler(ctx) {
1707
1959
  return
1708
1960
  }
1709
1961
 
1710
- // 📖 Y key freed Install Endpoints is now accessible only via Settings (P) or Command Palette (Ctrl+P).
1962
+ // 📖 Y key toggles favorites display mode (pinned+sticky vs normal rows).
1963
+ if (key.name === 'y' && !key.ctrl && !key.meta) {
1964
+ toggleFavoritesDisplayMode()
1965
+ return
1966
+ }
1711
1967
 
1712
- // 📖 Profile system removed - API keys now persist permanently across all sessions
1968
+ // 📖 X clears the active free-text filter set from the command palette.
1969
+ if (key.name === 'x' && !key.ctrl && !key.meta) {
1970
+ if (!state.customTextFilter) return
1971
+ state.customTextFilter = null
1972
+ applyTierFilter()
1973
+ refreshVisibleSorted({ resetCursor: true })
1974
+ return
1975
+ }
1713
1976
 
1714
1977
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1715
1978
 
@@ -1720,7 +1983,8 @@ export function createKeyHandler(ctx) {
1720
1983
  }
1721
1984
 
1722
1985
  // 📖 Sorting keys: R=rank, O=origin, M=model, L=latest ping, A=avg ping, S=SWE-bench, C=context, H=health, V=verdict, B=stability, U=uptime, G=usage
1723
- // 📖 T is reserved for tier filter cycling. Y is now free (Install Endpoints moved to Settings/Palette).
1986
+ // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
1987
+ // 📖 X clears the active custom text filter.
1724
1988
  // 📖 D is now reserved for provider filter cycling
1725
1989
  // 📖 Shift+R is reserved for reset view settings
1726
1990
  const sortKeys = {
@@ -1763,10 +2027,7 @@ export function createKeyHandler(ctx) {
1763
2027
  state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1764
2028
  saveConfig(state.config)
1765
2029
  applyTierFilter()
1766
- const visible = state.results.filter(r => !r.hidden)
1767
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1768
- state.cursor = 0
1769
- state.scrollOffset = 0
2030
+ refreshVisibleSorted({ resetCursor: true })
1770
2031
  return
1771
2032
  }
1772
2033
 
@@ -1775,10 +2036,7 @@ export function createKeyHandler(ctx) {
1775
2036
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
1776
2037
  applyTierFilter()
1777
2038
  // 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
1778
- const visible = state.results.filter(r => !r.hidden)
1779
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1780
- state.cursor = 0
1781
- state.scrollOffset = 0
2039
+ refreshVisibleSorted({ resetCursor: true })
1782
2040
  persistUiSettings()
1783
2041
  return
1784
2042
  }
@@ -1788,10 +2046,7 @@ export function createKeyHandler(ctx) {
1788
2046
  state.originFilterMode = (state.originFilterMode + 1) % ORIGIN_CYCLE.length
1789
2047
  applyTierFilter()
1790
2048
  // 📖 Recompute visible sorted list and reset cursor to avoid stale index into new filtered set
1791
- const visible = state.results.filter(r => !r.hidden)
1792
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
1793
- state.cursor = 0
1794
- state.scrollOffset = 0
2049
+ refreshVisibleSorted({ resetCursor: true })
1795
2050
  persistUiSettings()
1796
2051
  return
1797
2052
  }
@@ -1854,6 +2109,33 @@ export function createKeyHandler(ctx) {
1854
2109
  // 📖 Use the cached visible+sorted array — guaranteed to match what's on screen
1855
2110
  const selected = state.visibleSorted[state.cursor]
1856
2111
  if (!selected) return // 📖 Guard: empty visible list (all filtered out)
2112
+
2113
+ // 📖 Incompatibility intercept — if the model can't run on the active tool,
2114
+ // 📖 show the fallback overlay instead of launching. Lets user switch tool or pick similar model.
2115
+ if (!isModelCompatibleWithTool(selected.providerKey, state.mode)) {
2116
+ const compatTools = getCompatibleTools(selected.providerKey)
2117
+ const similarModels = findSimilarCompatibleModels(
2118
+ selected.sweScore || '-',
2119
+ state.mode,
2120
+ state.results.filter(r => r.providerKey !== selected.providerKey || r.modelId !== selected.modelId),
2121
+ 3
2122
+ )
2123
+ state.incompatibleFallbackOpen = true
2124
+ state.incompatibleFallbackCursor = 0
2125
+ state.incompatibleFallbackScrollOffset = 0
2126
+ state.incompatibleFallbackModel = {
2127
+ modelId: selected.modelId,
2128
+ label: selected.label,
2129
+ tier: selected.tier,
2130
+ providerKey: selected.providerKey,
2131
+ sweScore: selected.sweScore || '-',
2132
+ }
2133
+ state.incompatibleFallbackTools = compatTools
2134
+ state.incompatibleFallbackSimilarModels = similarModels
2135
+ state.incompatibleFallbackSection = 'tools'
2136
+ return
2137
+ }
2138
+
1857
2139
  if (shouldCheckMissingTool(state.mode) && !isToolInstalled(state.mode)) {
1858
2140
  state.toolInstallPromptOpen = true
1859
2141
  state.toolInstallPromptCursor = 0