free-coding-models 0.3.22 → 0.3.23

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/CHANGELOG.md CHANGED
@@ -1,6 +1,19 @@
1
1
  # Changelog
2
2
  ---
3
3
 
4
+ ## 0.3.23
5
+
6
+ ### Added
7
+ - **Favorites display mode (`Y`)**: Added a global toggle to switch favorites between `pinned + always visible` and `normal rows` (starred only, fully obeying active filters/sort).
8
+ - **Favorites mode row in Settings**: Added a dedicated Settings row to inspect/toggle favorites display behavior without leaving the maintenance screen.
9
+ - **Expanded command palette action menus**: Added nested menus for Ping Mode (speed/normal/slow/forced with explanations), Target Tool (all supported tools with explanations), and Favorites Mode (including the new `Y` flow).
10
+ - **Active text-filter footer alert**: When a custom search filter is active (for example `deep`), the last TUI footer line now shows a high-visibility inline badge (between `N Changelog` and `Ctrl+C Exit`) with the exact query and an `X` shortcut to clear it instantly.
11
+
12
+ ### Changed
13
+ - **Favorites sorting/filtering behavior**: Sort/filter logic now respects the selected favorites mode across the table refresh loop, hotkeys, and renderer so non-pinned mode behaves consistently everywhere.
14
+ - **Favorites default mode**: New/legacy configs now default to `Normal filter/sort` favorites mode (not pinned) until users press `Y` to opt into pinned+sticky behavior.
15
+ - **Footer/help/docs shortcut hints**: Surfaced `Y` in the TUI footer, Help overlay, and README so the new favorites mode is discoverable.
16
+
4
17
  ## 0.3.22
5
18
 
6
19
  ### Added
package/README.md CHANGED
@@ -185,6 +185,8 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
185
185
  | `D` | Cycle provider filter |
186
186
  | `E` | Toggle configured-only mode |
187
187
  | `F` | Favorite / unfavorite model |
188
+ | `Y` | Toggle favorites mode (`Normal filter/sort` default ↔ `Pinned + always visible`) |
189
+ | `X` | Clear active custom text filter |
188
190
  | `G` | Cycle global theme (`Auto → Dark → Light`) |
189
191
  | `Ctrl+P` | Open ⚡️ command palette (search + run actions) |
190
192
  | `R/S/C/M/O/L/A/H/V/B/U` | Sort columns |
@@ -206,12 +208,12 @@ Press **`Z`** in the TUI to cycle between tools without restarting.
206
208
  - **Adaptive monitoring** — 2s burst for 60s → 10s normal → 30s idle
207
209
  - **Stability score** — composite 0–100 (p95 latency, jitter, spike rate, uptime)
208
210
  - **Smart ranking** — top 3 highlighted 🥇🥈🥉
209
- - **Favorites** — pin models with `F`, persisted across sessions
211
+ - **Favorites** — star models with `F`, persisted across sessions, default to normal rows, and switch display mode with `Y` (pinned+sticky vs normal rows)
210
212
  - **Configured-only default** — only shows providers you have keys for
211
213
  - **Keyless latency** — models ping even without an API key (show 🔑 NO KEY)
212
214
  - **Smart Recommend** — questionnaire picks the best model for your task type
213
215
  - **⚡️ Command Palette** — `Ctrl+P` opens a searchable action launcher for filters, sorting, overlays, and quick toggles
214
- - **Install Endpoints** — push a full provider catalog into any tool's config (`Y`)
216
+ - **Install Endpoints** — push a full provider catalog into any tool's config (from Settings `P` or ⚡️ Command Palette)
215
217
  - **Missing tool bootstrap** — detect absent CLIs, offer one-click install, then continue the selected launch automatically
216
218
  - **Width guardrail** — shows a warning instead of a broken table in narrow terminals
217
219
  - **Readable everywhere** — semantic theme palette keeps table rows, overlays, badges, and help screens legible in dark and light terminals
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.22",
3
+ "version": "0.3.23",
4
4
  "description": "Find the fastest coding LLM models in seconds — ping free models from multiple providers, pick the best one for OpenCode, Cursor, or any AI coding assistant.",
5
5
  "keywords": [
6
6
  "nvidia",
package/src/app.js CHANGED
@@ -21,8 +21,8 @@
21
21
  * - JSON config stored in ~/.free-coding-models.json (auto-migrates from old plain-text)
22
22
  * - Multi-provider support via sources.js (NIM/Groq/Cerebras/OpenRouter/Hugging Face/Replicate/DeepInfra/... — extensible)
23
23
  * - Settings screen (P key) to manage API keys, provider toggles, manual updates, and provider-key diagnostics
24
- * - Install Endpoints flow (Y key) to push provider catalogs into OpenCode, OpenClaw, Crush, and Goose
25
- * - Favorites system: toggle with F, pin rows to top, persist between sessions
24
+ * - Install Endpoints flow (Settings / Command Palette) to push provider catalogs into OpenCode, OpenClaw, Crush, and Goose
25
+ * - Favorites system: toggle with F, switch pinning mode with Y, persist between sessions
26
26
  * - Uptime percentage tracking (successful pings / total pings)
27
27
  * - Sortable columns (R/O/M/L/A/S/C/H/V/B/U/G keys)
28
28
  * - Tier filtering via T key (cycles S+→S→A+→A→A-→B+→B→C→All)
@@ -307,7 +307,7 @@ export async function runApp(cliArgs, config) {
307
307
  }
308
308
 
309
309
  // 📖 Re-sync tracked external-tool catalogs after the live provider catalog has settled.
310
- // 📖 This keeps prior `Y` installs aligned with the current FCM model list.
310
+ // 📖 This keeps prior endpoint installs aligned with the current FCM model list.
311
311
  refreshInstalledEndpoints(config)
312
312
 
313
313
  // 📖 Build results from MODELS — only include enabled providers
@@ -383,6 +383,7 @@ export async function runApp(cliArgs, config) {
383
383
  tierFilterMode: 0, // 📖 Index into TIER_CYCLE (0=All, 1=S+, 2=S, ...)
384
384
  originFilterMode: 0, // 📖 Index into ORIGIN_CYCLE (0=All, then providers)
385
385
  hideUnconfiguredModels: config.settings?.hideUnconfiguredModels === true, // 📖 Hide providers with no configured API key when true.
386
+ favoritesPinnedAndSticky: config.settings?.favoritesPinnedAndSticky === true, // 📖 false by default: favorites follow normal sort/filter rules until Y enables pinned+sticky mode.
386
387
  scrollOffset: 0, // 📖 First visible model index in viewport
387
388
  terminalRows: process.stdout.rows || 24, // 📖 Current terminal height
388
389
  terminalCols: process.stdout.columns || 80, // 📖 Current terminal width
@@ -409,11 +410,11 @@ export async function runApp(cliArgs, config) {
409
410
  commandPaletteScrollOffset: 0, // 📖 Vertical scroll offset for the command palette result viewport.
410
411
  commandPaletteResults: [], // 📖 Cached fuzzy-filtered command entries for the command palette.
411
412
  commandPaletteFrozenTable: null, // 📖 Frozen table snapshot rendered behind the command palette overlay.
412
- commandPaletteExpandedIds: new Set(['filters']), // 📖 Set of expanded category/subcategory IDs (filters expanded by default for quick access).
413
+ commandPaletteExpandedIds: new Set(['filters', 'actions']), // 📖 Expanded category IDs (filters + actions open by default for quick access).
413
414
  helpVisible: false, // 📖 Whether the help overlay (K key) is active
414
415
  settingsScrollOffset: 0, // 📖 Vertical scroll offset for Settings overlay viewport
415
416
  helpScrollOffset: 0, // 📖 Vertical scroll offset for Help overlay viewport
416
- // 📖 Install Endpoints overlay state (Y key opens it)
417
+ // 📖 Install Endpoints overlay state (opened from Settings or Command Palette)
417
418
  installEndpointsOpen: false, // 📖 Whether the install-endpoints overlay is active
418
419
  installEndpointsPhase: 'providers', // 📖 providers | tools | scope | models | result
419
420
  installEndpointsCursor: 0, // 📖 Selected row within the current install phase
@@ -678,8 +679,8 @@ export async function runApp(cliArgs, config) {
678
679
  const activeTier = TIER_CYCLE[state.tierFilterMode]
679
680
  const activeOrigin = ORIGIN_CYCLE[state.originFilterMode]
680
681
  state.results.forEach(r => {
681
- // 📖 Favorites stay visible and pinned regardless of configured-only, tier, or provider filters.
682
- if (r.isFavorite) {
682
+ // 📖 Sticky-favorites mode keeps favorites visible regardless of configured-only, tier, or provider filters.
683
+ if (state.favoritesPinnedAndSticky && r.isFavorite) {
683
684
  r.hidden = false
684
685
  return
685
686
  }
@@ -825,7 +826,7 @@ export async function runApp(cliArgs, config) {
825
826
  if (cliArgs.tierFilter) {
826
827
  const allowed = TIER_LETTER_MAP[cliArgs.tierFilter]
827
828
  state.results.forEach(r => {
828
- r.hidden = r.isFavorite ? false : !allowed.includes(r.tier)
829
+ r.hidden = (state.favoritesPinnedAndSticky && r.isFavorite) ? false : !allowed.includes(r.tier)
829
830
  })
830
831
  }
831
832
 
@@ -863,7 +864,9 @@ export async function runApp(cliArgs, config) {
863
864
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
864
865
  if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.commandPaletteOpen) {
865
866
  const visible = state.results.filter(r => !r.hidden)
866
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
867
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
868
+ pinFavorites: state.favoritesPinnedAndSticky,
869
+ })
867
870
  }
868
871
  let tableContent = null
869
872
  if (state.commandPaletteOpen) {
@@ -896,7 +899,9 @@ export async function runApp(cliArgs, config) {
896
899
  state.settingsUpdateLatestVersion,
897
900
  false,
898
901
  state.startupLatestVersion,
899
- state.versionAlertsEnabled
902
+ state.versionAlertsEnabled,
903
+ state.favoritesPinnedAndSticky,
904
+ state.customTextFilter
900
905
  )
901
906
  }
902
907
  tableContent = state.commandPaletteFrozenTable
@@ -928,7 +933,9 @@ export async function runApp(cliArgs, config) {
928
933
  state.settingsUpdateLatestVersion,
929
934
  false,
930
935
  state.startupLatestVersion,
931
- state.versionAlertsEnabled
936
+ state.versionAlertsEnabled,
937
+ state.favoritesPinnedAndSticky,
938
+ state.customTextFilter
932
939
  )
933
940
  }
934
941
 
@@ -964,9 +971,11 @@ export async function runApp(cliArgs, config) {
964
971
 
965
972
  // 📖 Populate visibleSorted before the first frame so Enter works immediately
966
973
  const initialVisible = state.results.filter(r => !r.hidden)
967
- state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection)
974
+ state.visibleSorted = sortResultsWithPinnedFavorites(initialVisible, state.sortColumn, state.sortDirection, {
975
+ pinFavorites: state.favoritesPinnedAndSticky,
976
+ })
968
977
 
969
- process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled))
978
+ process.stdout.write(ALT_HOME + renderTable(state.results, state.pendingPings, state.frame, state.cursor, state.sortColumn, state.sortDirection, state.pingInterval, state.lastPingTime, state.mode, state.tierFilterMode, state.scrollOffset, state.terminalRows, state.terminalCols, state.originFilterMode, null, state.pingMode, state.pingModeSource, state.hideUnconfiguredModels, state.widthWarningStartedAt, state.widthWarningDismissed, state.widthWarningShowCount, state.settingsUpdateState, state.settingsUpdateLatestVersion, false, state.startupLatestVersion, state.versionAlertsEnabled, state.favoritesPinnedAndSticky, state.customTextFilter))
970
979
  if (process.stdout.isTTY) {
971
980
  process.stdout.flush && process.stdout.flush()
972
981
  }
@@ -15,6 +15,72 @@
15
15
  * @see src/overlays.js
16
16
  */
17
17
 
18
+ import { TOOL_METADATA, TOOL_MODE_ORDER } from './tool-metadata.js'
19
+
20
+ const TOOL_MODE_DESCRIPTIONS = {
21
+ opencode: 'Launch in OpenCode CLI with the selected model.',
22
+ 'opencode-desktop': 'Set model in shared config, then open OpenCode Desktop.',
23
+ openclaw: 'Set default model in OpenClaw and launch it.',
24
+ crush: 'Launch Crush with this provider/model pair.',
25
+ goose: 'Launch Goose and preselect the active model.',
26
+ pi: 'Launch Pi with model/provider flags.',
27
+ aider: 'Launch Aider configured on the selected model.',
28
+ qwen: 'Launch Qwen Code using the selected provider model.',
29
+ openhands: 'Launch OpenHands with the selected model endpoint.',
30
+ amp: 'Launch Amp with this model as active target.',
31
+ }
32
+
33
+ const TOOL_MODE_COMMANDS = TOOL_MODE_ORDER.map((toolMode) => {
34
+ const meta = TOOL_METADATA[toolMode] || { label: toolMode, emoji: '🧰' }
35
+ return {
36
+ id: `action-set-tool-${toolMode}`,
37
+ label: meta.label,
38
+ toolMode,
39
+ icon: meta.emoji,
40
+ description: TOOL_MODE_DESCRIPTIONS[toolMode] || 'Set this as the active launch target.',
41
+ keywords: ['tool', 'target', 'mode', toolMode, meta.label.toLowerCase()],
42
+ }
43
+ })
44
+
45
+ const PING_MODE_COMMANDS = [
46
+ {
47
+ id: 'action-cycle-ping-mode',
48
+ label: 'Cycle ping mode',
49
+ shortcut: 'W',
50
+ icon: '⚡',
51
+ description: 'Rotate speed → normal → slow → forced.',
52
+ keywords: ['ping', 'mode', 'cycle', 'speed', 'normal', 'slow', 'forced'],
53
+ },
54
+ {
55
+ id: 'action-set-ping-speed',
56
+ label: 'Speed mode (2s)',
57
+ pingMode: 'speed',
58
+ description: 'Fast 2s bursts for short live checks.',
59
+ keywords: ['ping', 'mode', 'speed', '2s', 'fast'],
60
+ },
61
+ {
62
+ id: 'action-set-ping-normal',
63
+ label: 'Normal mode (10s)',
64
+ pingMode: 'normal',
65
+ description: 'Balanced default cadence for daily use.',
66
+ keywords: ['ping', 'mode', 'normal', '10s', 'default'],
67
+ },
68
+ {
69
+ id: 'action-set-ping-slow',
70
+ label: 'Slow mode (30s)',
71
+ pingMode: 'slow',
72
+ description: 'Lower refresh cost when you are mostly idle.',
73
+ keywords: ['ping', 'mode', 'slow', '30s', 'idle'],
74
+ },
75
+ {
76
+ id: 'action-set-ping-forced',
77
+ label: 'Forced mode (4s)',
78
+ pingMode: 'forced',
79
+ description: 'Keeps 4s cadence until manually changed.',
80
+ keywords: ['ping', 'mode', 'forced', '4s', 'manual'],
81
+ },
82
+ ]
83
+
18
84
  // 📖 Base command tree template (will be enhanced with dynamic model list)
19
85
  const BASE_COMMAND_TREE = [
20
86
  {
@@ -93,6 +159,41 @@ const BASE_COMMAND_TREE = [
93
159
  { id: 'sort-uptime', label: 'Sort by uptime', shortcut: 'U', description: 'Success rate', keywords: ['sort', 'uptime'] },
94
160
  ]
95
161
  },
162
+ {
163
+ id: 'actions',
164
+ label: 'Actions',
165
+ icon: '⚙️',
166
+ children: [
167
+ {
168
+ id: 'action-target-tool',
169
+ label: 'Target tool',
170
+ icon: '🧰',
171
+ children: [
172
+ { id: 'action-cycle-tool-mode', label: 'Cycle target tool', shortcut: 'Z', icon: '🔄', description: 'Rotate through every launcher mode.', keywords: ['tool', 'mode', 'cycle', 'target'] },
173
+ ...TOOL_MODE_COMMANDS,
174
+ ],
175
+ },
176
+ {
177
+ id: 'action-ping-mode',
178
+ label: 'Ping mode',
179
+ icon: '📶',
180
+ children: PING_MODE_COMMANDS,
181
+ },
182
+ {
183
+ id: 'action-favorites-mode',
184
+ label: 'Favorites mode',
185
+ icon: '⭐',
186
+ children: [
187
+ { id: 'action-toggle-favorite-mode', label: 'Toggle favorites mode', shortcut: 'Y', icon: '⭐', description: 'Switch pinned+sticky ↔ normal list behavior.', keywords: ['favorite', 'favorites', 'mode', 'toggle', 'y'] },
188
+ { id: 'action-favorites-mode-pinned', label: 'Pinned + always visible', favoritesPinned: true, description: 'Favorites stay on top and bypass current filters.', keywords: ['favorite', 'favorites', 'pinned', 'sticky', 'always visible'] },
189
+ { id: 'action-favorites-mode-normal', label: 'Normal rows (starred only)', favoritesPinned: false, description: 'Favorites keep ⭐ but follow active filters and sort.', keywords: ['favorite', 'favorites', 'normal', 'sort', 'filter'] },
190
+ { id: 'action-toggle-favorite', label: 'Toggle favorite on selected row', shortcut: 'F', icon: '⭐', description: 'Star/unstar the highlighted model.', keywords: ['favorite', 'star', 'toggle'] },
191
+ ],
192
+ },
193
+ { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
194
+ { id: 'action-reset-view', label: 'Reset view', shortcut: 'Shift+R', icon: '🔄', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
195
+ ],
196
+ },
96
197
  // 📖 Pages - directly at root level, not in submenu
97
198
  { id: 'open-settings', label: 'Settings', shortcut: 'P', icon: '⚙️', type: 'page', description: 'API keys and preferences', keywords: ['settings', 'config', 'api key'] },
98
199
  { id: 'open-help', label: 'Help', shortcut: 'K', icon: '❓', type: 'page', description: 'Show all shortcuts', keywords: ['help', 'shortcuts', 'hotkeys'] },
@@ -100,12 +201,6 @@ const BASE_COMMAND_TREE = [
100
201
  { id: 'open-feedback', label: 'Feedback', shortcut: 'I', icon: '📝', type: 'page', description: 'Report bugs or requests', keywords: ['feedback', 'bug', 'request'] },
101
202
  { id: 'open-recommend', label: 'Smart recommend', shortcut: 'Q', icon: '🎯', type: 'page', description: 'Find best model for task', keywords: ['recommend', 'best model'] },
102
203
  { id: 'open-install-endpoints', label: 'Install endpoints', icon: '🔌', type: 'page', description: 'Install provider catalogs', keywords: ['install', 'endpoints', 'providers'] },
103
- // 📖 Actions - directly at root level, not in submenu
104
- { id: 'action-cycle-theme', label: 'Cycle theme', shortcut: 'G', icon: '🌗', type: 'action', description: 'Switch dark/light/auto', keywords: ['theme', 'dark', 'light', 'auto'] },
105
- { id: 'action-cycle-tool-mode', label: 'Target Tool', shortcut: 'Z', icon: '🔄', type: 'action', description: 'Change target AI Coding CLI Tool.', keywords: ['tool', 'mode', 'launcher', 'target'] },
106
- { id: 'action-cycle-ping-mode', label: 'Cycle ping mode', shortcut: 'W', icon: '⚡', type: 'action', description: 'Adjust ping speed', keywords: ['ping', 'cadence', 'speed', 'slow'] },
107
- { id: 'action-toggle-favorite', label: 'Toggle favorite', shortcut: 'F', icon: '⭐', type: 'action', description: 'Pin to favorites', keywords: ['favorite', 'star'] },
108
- { id: 'action-reset-view', label: 'Reset view', shortcut: 'Shift+R', icon: '🔄', type: 'action', description: 'Reset filters and sort', keywords: ['reset', 'view', 'sort', 'filters'] },
109
204
  ]
110
205
 
111
206
  /**
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`)
@@ -519,7 +519,9 @@ export function createKeyHandler(ctx) {
519
519
  // 📖 Shared table refresh helper so command-palette and hotkeys keep identical behavior.
520
520
  function refreshVisibleSorted({ resetCursor = true } = {}) {
521
521
  const visible = state.results.filter(r => !r.hidden)
522
- state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection)
522
+ state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
523
+ pinFavorites: state.favoritesPinnedAndSticky,
524
+ })
523
525
  if (resetCursor) {
524
526
  state.cursor = 0
525
527
  state.scrollOffset = 0
@@ -600,12 +602,48 @@ export function createKeyHandler(ctx) {
600
602
  const modeOrder = getToolModeOrder()
601
603
  const currentIndex = modeOrder.indexOf(state.mode)
602
604
  const nextIndex = (currentIndex + 1) % modeOrder.length
603
- state.mode = modeOrder[nextIndex]
605
+ setToolMode(modeOrder[nextIndex])
606
+ }
607
+
608
+ // 📖 Keep tool-mode changes centralized so keyboard shortcuts and command palette
609
+ // 📖 both persist to config exactly the same way.
610
+ function setToolMode(nextMode) {
611
+ if (!getToolModeOrder().includes(nextMode)) return
612
+ state.mode = nextMode
604
613
  if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
605
614
  state.config.settings.preferredToolMode = state.mode
606
615
  saveConfig(state.config)
607
616
  }
608
617
 
618
+ // 📖 Favorites display mode:
619
+ // 📖 - true => favorites stay pinned + always visible (legacy behavior)
620
+ // 📖 - false => favorites are just starred rows and obey normal sort/filter rules
621
+ function setFavoritesDisplayMode(nextPinned, { preserveSelection = true } = {}) {
622
+ const normalizedNextPinned = nextPinned !== false
623
+ if (state.favoritesPinnedAndSticky === normalizedNextPinned) return
624
+
625
+ const selected = preserveSelection ? state.visibleSorted[state.cursor] : null
626
+ const selectedKey = selected ? toFavoriteKey(selected.providerKey, selected.modelId) : null
627
+
628
+ state.favoritesPinnedAndSticky = normalizedNextPinned
629
+ if (!state.config.settings || typeof state.config.settings !== 'object') state.config.settings = {}
630
+ state.config.settings.favoritesPinnedAndSticky = state.favoritesPinnedAndSticky
631
+ saveConfig(state.config)
632
+
633
+ applyTierFilter()
634
+ refreshVisibleSorted({ resetCursor: false })
635
+
636
+ if (selectedKey) {
637
+ const selectedIdx = state.visibleSorted.findIndex((row) => toFavoriteKey(row.providerKey, row.modelId) === selectedKey)
638
+ if (selectedIdx >= 0) state.cursor = selectedIdx
639
+ adjustScrollOffset(state)
640
+ }
641
+ }
642
+
643
+ function toggleFavoritesDisplayMode() {
644
+ setFavoritesDisplayMode(!state.favoritesPinnedAndSticky)
645
+ }
646
+
609
647
  function resetViewSettings() {
610
648
  state.tierFilterMode = 0
611
649
  state.originFilterMode = 0
@@ -631,7 +669,7 @@ export function createKeyHandler(ctx) {
631
669
  applyTierFilter()
632
670
  refreshVisibleSorted({ resetCursor: false })
633
671
 
634
- if (wasFavorite) {
672
+ if (wasFavorite && state.favoritesPinnedAndSticky) {
635
673
  state.cursor = 0
636
674
  state.scrollOffset = 0
637
675
  return
@@ -654,11 +692,28 @@ export function createKeyHandler(ctx) {
654
692
  }
655
693
 
656
694
  function refreshCommandPaletteResults() {
695
+ const query = (state.commandPaletteQuery || '').trim()
657
696
  const tree = buildCommandPaletteTree(state.results || [])
658
- const flat = flattenCommandTree(tree, state.commandPaletteExpandedIds)
659
- state.commandPaletteResults = filterCommandPaletteEntries(flat, state.commandPaletteQuery)
697
+ // 📖 Keep collapsed view clean when query is empty, but search across the
698
+ // 📖 full tree when users type so hidden submenu commands still appear.
699
+ let flat
700
+ if (query.length > 0) {
701
+ const expandedIds = new Set()
702
+ const collectExpandedIds = (nodes) => {
703
+ for (const node of nodes || []) {
704
+ if (Array.isArray(node.children) && node.children.length > 0) {
705
+ expandedIds.add(node.id)
706
+ collectExpandedIds(node.children)
707
+ }
708
+ }
709
+ }
710
+ collectExpandedIds(tree)
711
+ flat = flattenCommandTree(tree, expandedIds)
712
+ } else {
713
+ flat = flattenCommandTree(tree, state.commandPaletteExpandedIds)
714
+ }
715
+ state.commandPaletteResults = filterCommandPaletteEntries(flat, query)
660
716
 
661
- const query = (state.commandPaletteQuery || '').trim()
662
717
  if (query.length > 0) {
663
718
  state.commandPaletteResults.unshift({
664
719
  id: 'filter-custom-text-apply',
@@ -707,6 +762,21 @@ export function createKeyHandler(ctx) {
707
762
  function executeCommandPaletteEntry(entry) {
708
763
  if (!entry?.id) return
709
764
 
765
+ if (entry.id.startsWith('action-set-ping-') && entry.pingMode) {
766
+ setPingMode(entry.pingMode, 'manual')
767
+ return
768
+ }
769
+
770
+ if (entry.id.startsWith('action-set-tool-') && entry.toolMode) {
771
+ setToolMode(entry.toolMode)
772
+ return
773
+ }
774
+
775
+ if (entry.id.startsWith('action-favorites-mode-') && typeof entry.favoritesPinned === 'boolean') {
776
+ setFavoritesDisplayMode(entry.favoritesPinned)
777
+ return
778
+ }
779
+
710
780
  if (entry.id.startsWith('filter-tier-')) {
711
781
  setTierFilterFromCommand(entry.tier ?? null)
712
782
  return
@@ -793,6 +863,7 @@ export function createKeyHandler(ctx) {
793
863
  return
794
864
  }
795
865
  case 'action-toggle-favorite': return toggleFavoriteOnSelectedRow()
866
+ case 'action-toggle-favorite-mode': return toggleFavoritesDisplayMode()
796
867
  case 'action-reset-view': return resetViewSettings()
797
868
  default:
798
869
  return
@@ -1442,7 +1513,8 @@ export function createKeyHandler(ctx) {
1442
1513
  const providerKeys = Object.keys(sources)
1443
1514
  const updateRowIdx = providerKeys.length
1444
1515
  const themeRowIdx = updateRowIdx + 1
1445
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
1516
+ const favoritesModeRowIdx = themeRowIdx + 1
1517
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
1446
1518
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
1447
1519
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1448
1520
  const maxRowIdx = changelogViewRowIdx
@@ -1523,10 +1595,7 @@ export function createKeyHandler(ctx) {
1523
1595
  setResults(nextResults)
1524
1596
  syncFavoriteFlags(state.results, state.config)
1525
1597
  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)
1598
+ refreshVisibleSorted({ resetCursor: false })
1530
1599
  // 📖 Re-ping all models that were 'noauth' (got 401 without key) but now have a key
1531
1600
  // 📖 This makes the TUI react immediately when a user adds an API key in settings
1532
1601
  const pingModel = getPingModel?.()
@@ -1591,6 +1660,11 @@ export function createKeyHandler(ctx) {
1591
1660
  return
1592
1661
  }
1593
1662
 
1663
+ if (state.settingsCursor === favoritesModeRowIdx) {
1664
+ toggleFavoritesDisplayMode()
1665
+ return
1666
+ }
1667
+
1594
1668
  if (state.settingsCursor === cleanupLegacyProxyRowIdx) {
1595
1669
  runLegacyProxyCleanup()
1596
1670
  return
@@ -1629,6 +1703,10 @@ export function createKeyHandler(ctx) {
1629
1703
  cycleGlobalTheme()
1630
1704
  return
1631
1705
  }
1706
+ if (state.settingsCursor === favoritesModeRowIdx) {
1707
+ toggleFavoritesDisplayMode()
1708
+ return
1709
+ }
1632
1710
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1633
1711
 
1634
1712
  // 📖 Toggle enabled/disabled for selected provider
@@ -1644,6 +1722,7 @@ export function createKeyHandler(ctx) {
1644
1722
  if (
1645
1723
  state.settingsCursor === updateRowIdx
1646
1724
  || state.settingsCursor === themeRowIdx
1725
+ || state.settingsCursor === favoritesModeRowIdx
1647
1726
  || state.settingsCursor === cleanupLegacyProxyRowIdx
1648
1727
  || state.settingsCursor === changelogViewRowIdx
1649
1728
  ) return
@@ -1661,6 +1740,12 @@ export function createKeyHandler(ctx) {
1661
1740
  return
1662
1741
  }
1663
1742
 
1743
+ // 📖 Y toggles favorites display mode directly from Settings.
1744
+ if (key.name === 'y') {
1745
+ toggleFavoritesDisplayMode()
1746
+ return
1747
+ }
1748
+
1664
1749
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1665
1750
 
1666
1751
  if (key.ctrl && key.name === 'c') { exit(0); return }
@@ -1707,7 +1792,20 @@ export function createKeyHandler(ctx) {
1707
1792
  return
1708
1793
  }
1709
1794
 
1710
- // 📖 Y key freed Install Endpoints is now accessible only via Settings (P) or Command Palette (Ctrl+P).
1795
+ // 📖 Y key toggles favorites display mode (pinned+sticky vs normal rows).
1796
+ if (key.name === 'y' && !key.ctrl && !key.meta) {
1797
+ toggleFavoritesDisplayMode()
1798
+ return
1799
+ }
1800
+
1801
+ // 📖 X clears the active free-text filter set from the command palette.
1802
+ if (key.name === 'x' && !key.ctrl && !key.meta) {
1803
+ if (!state.customTextFilter) return
1804
+ state.customTextFilter = null
1805
+ applyTierFilter()
1806
+ refreshVisibleSorted({ resetCursor: true })
1807
+ return
1808
+ }
1711
1809
 
1712
1810
  // 📖 Profile system removed - API keys now persist permanently across all sessions
1713
1811
 
@@ -1720,7 +1818,8 @@ export function createKeyHandler(ctx) {
1720
1818
  }
1721
1819
 
1722
1820
  // 📖 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).
1821
+ // 📖 T is reserved for tier filter cycling. Y toggles favorites display mode.
1822
+ // 📖 X clears the active custom text filter.
1724
1823
  // 📖 D is now reserved for provider filter cycling
1725
1824
  // 📖 Shift+R is reserved for reset view settings
1726
1825
  const sortKeys = {
@@ -1763,10 +1862,7 @@ export function createKeyHandler(ctx) {
1763
1862
  state.config.settings.hideUnconfiguredModels = state.hideUnconfiguredModels
1764
1863
  saveConfig(state.config)
1765
1864
  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
1865
+ refreshVisibleSorted({ resetCursor: true })
1770
1866
  return
1771
1867
  }
1772
1868
 
@@ -1775,10 +1871,7 @@ export function createKeyHandler(ctx) {
1775
1871
  state.tierFilterMode = (state.tierFilterMode + 1) % TIER_CYCLE.length
1776
1872
  applyTierFilter()
1777
1873
  // 📖 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
1874
+ refreshVisibleSorted({ resetCursor: true })
1782
1875
  persistUiSettings()
1783
1876
  return
1784
1877
  }
@@ -1788,10 +1881,7 @@ export function createKeyHandler(ctx) {
1788
1881
  state.originFilterMode = (state.originFilterMode + 1) % ORIGIN_CYCLE.length
1789
1882
  applyTierFilter()
1790
1883
  // 📖 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
1884
+ refreshVisibleSorted({ resetCursor: true })
1795
1885
  persistUiSettings()
1796
1886
  return
1797
1887
  }
package/src/overlays.js CHANGED
@@ -94,7 +94,8 @@ export function createOverlayRenderers(state, deps) {
94
94
  const providerKeys = Object.keys(sources)
95
95
  const updateRowIdx = providerKeys.length
96
96
  const themeRowIdx = updateRowIdx + 1
97
- const cleanupLegacyProxyRowIdx = themeRowIdx + 1
97
+ const favoritesModeRowIdx = themeRowIdx + 1
98
+ const cleanupLegacyProxyRowIdx = favoritesModeRowIdx + 1
98
99
  const changelogViewRowIdx = cleanupLegacyProxyRowIdx + 1
99
100
  const EL = '\x1b[K'
100
101
  const lines = []
@@ -224,6 +225,16 @@ export function createOverlayRenderers(state, deps) {
224
225
  const themeRow = `${bullet(state.settingsCursor === themeRowIdx)}${themeColors.textBold('Global Theme').padEnd(44)} ${themeStatusColor(themeStatus)}`
225
226
  cursorLineByRow[themeRowIdx] = lines.length
226
227
  lines.push(state.settingsCursor === themeRowIdx ? themeColors.bgCursor(themeRow) : themeRow)
228
+
229
+ // 📖 Favorites mode row mirrors Y-key behavior from the main table.
230
+ const favoritesModeEnabled = state.favoritesPinnedAndSticky === true
231
+ const favoritesModeStatus = favoritesModeEnabled
232
+ ? themeColors.warningBold('Pinned + always visible')
233
+ : themeColors.info('Normal rows (filter/sort)')
234
+ const favoritesModeRow = `${bullet(state.settingsCursor === favoritesModeRowIdx)}${themeColors.textBold('Favorites Display Mode').padEnd(44)} ${favoritesModeStatus}`
235
+ cursorLineByRow[favoritesModeRowIdx] = lines.length
236
+ lines.push(state.settingsCursor === favoritesModeRowIdx ? themeColors.bgCursorSettingsList(favoritesModeRow) : favoritesModeRow)
237
+
227
238
  if (updateState === 'error' && state.settingsUpdateError) {
228
239
  lines.push(themeColors.error(` ${state.settingsUpdateError}`))
229
240
  }
@@ -244,7 +255,7 @@ export function createOverlayRenderers(state, deps) {
244
255
  if (state.settingsEditMode) {
245
256
  lines.push(themeColors.dim(' Type API key • Enter Save • Esc Cancel'))
246
257
  } else {
247
- lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Global theme • Esc Close'))
258
+ lines.push(themeColors.dim(' ↑↓ Navigate • Enter Edit/Run/Cycle • + Add key • - Remove key • Space Toggle/Cycle • T Test key • U Updates • G Global theme • Y Favorites mode • Esc Close'))
248
259
  }
249
260
  // 📖 Show sync/restore status message if set
250
261
  if (state.settingsSyncStatus) {
@@ -283,7 +294,7 @@ export function createOverlayRenderers(state, deps) {
283
294
 
284
295
  // ─── Install Endpoints overlay renderer ───────────────────────────────────
285
296
  // 📖 renderInstallEndpoints drives the provider → tool → scope → model flow
286
- // 📖 behind the `Y` hotkey. It deliberately reuses the same overlay viewport
297
+ // 📖 opened from Settings/Command Palette. It deliberately reuses the same overlay viewport
287
298
  // 📖 helpers as Settings so long provider/model lists stay navigable.
288
299
  function renderInstallEndpoints() {
289
300
  const EL = '\x1b[K'
@@ -713,8 +724,8 @@ export function createOverlayRenderers(state, deps) {
713
724
  lines.push(` ${label('CTX')} Context window size (128k, 200k, 256k, 1m, etc.) ${hint('Sort:')} ${key('C')}`)
714
725
  lines.push(` ${hint('Bigger context = the model can read more of your codebase at once without forgetting.')}`)
715
726
  lines.push('')
716
- lines.push(` ${label('Model')} Model name (⭐ = favorited, pinned at top) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
717
- lines.push(` ${hint('Star the ones you like they stay pinned at the top across restarts.')}`)
727
+ lines.push(` ${label('Model')} Model name (⭐ = favorited) ${hint('Sort:')} ${key('M')} ${hint('Favorite:')} ${key('F')}`)
728
+ lines.push(` ${hint('Star the ones you like. Press Y to switch between pinned mode and normal filter/sort mode.')}`)
718
729
  lines.push('')
719
730
  lines.push(` ${label('Provider')} Provider source (NIM, Groq, Cerebras, etc.) ${hint('Sort:')} ${key('O')} ${hint('Cycle:')} ${key('D')}`)
720
731
  lines.push(` ${hint('Same model on different providers can have very different speed and uptime.')}`)
@@ -754,7 +765,9 @@ export function createOverlayRenderers(state, deps) {
754
765
  lines.push(` ${key('Ctrl+P')} Open ⚡️ command palette ${hint('(search and run actions quickly)')}`)
755
766
  lines.push(` ${key('E')} Toggle configured models only ${hint('(enabled by default)')}`)
756
767
  lines.push(` ${key('Z')} Cycle tool mode ${hint('(OpenCode → Desktop → OpenClaw → Crush → Goose → Pi → Aider → Qwen → OpenHands → Amp)')}`)
757
- lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ pinned at top, persisted)')}`)
768
+ lines.push(` ${key('F')} Toggle favorite on selected row ${hint('(⭐ persisted across sessions)')}`)
769
+ lines.push(` ${key('Y')} Toggle favorites mode ${hint('(Pinned + always visible ↔ Normal filter/sort behavior)')}`)
770
+ lines.push(` ${key('X')} Clear active text filter ${hint('(remove custom query applied from ⚡️ Command Palette)')}`)
758
771
  lines.push(` ${key('Q')} Smart Recommend ${hint('(🎯 find the best model for your task — questionnaire + live analysis)')}`)
759
772
  lines.push(` ${key('G')} Cycle theme ${hint('(auto → dark → light)')}`)
760
773
  lines.push(` ${themeColors.errorBold('I')} Feedback, bugs & requests ${hint('(📝 send anonymous feedback, bug reports, or feature requests)')}`)
@@ -770,7 +783,8 @@ export function createOverlayRenderers(state, deps) {
770
783
  lines.push(` ${key('PgUp/PgDn')} Jump by page`)
771
784
  lines.push(` ${key('Home/End')} Jump first/last row`)
772
785
  lines.push(` ${key('Enter')} Edit key / run selected maintenance action`)
773
- lines.push(` ${key('Space')} Toggle provider enable/disable`)
786
+ lines.push(` ${key('Space')} Toggle selected row option (provider/theme/favorites)`)
787
+ lines.push(` ${key('Y')} Toggle favorites mode (global)`)
774
788
  lines.push(` ${key('T')} Test selected provider key`)
775
789
  lines.push(` ${key('U')} Check updates manually`)
776
790
  lines.push(` ${key('G')} Cycle theme globally`)
@@ -168,10 +168,23 @@ export function calculateViewport(terminalRows, scrollOffset, totalModels, extra
168
168
 
169
169
  // 📖 sortResultsWithPinnedFavorites: Recommended models are pinned above favorites, favorites above non-favorites.
170
170
  // 📖 Recommended: sorted by recommendation score (highest first).
171
- // 📖 Favorites: keep insertion order (favoriteRank).
171
+ // 📖 Favorites: keep insertion order (favoriteRank) when pinFavorites=true.
172
172
  // 📖 Non-favorites: active sort column/direction.
173
173
  // 📖 Models that are both recommended AND favorite — show in recommended section.
174
- export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection) {
174
+ // 📖 pinFavorites=false keeps favorites highlighted but lets normal sort/filter order apply.
175
+ export function sortResultsWithPinnedFavorites(results, sortColumn, sortDirection, { pinFavorites = true } = {}) {
176
+ if (!pinFavorites) {
177
+ const recommendedRows = results
178
+ .filter((r) => r.isRecommended)
179
+ .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
180
+ const nonRecommendedRows = sortResults(
181
+ results.filter((r) => !r.isRecommended),
182
+ sortColumn,
183
+ sortDirection
184
+ )
185
+ return [...recommendedRows, ...nonRecommendedRows]
186
+ }
187
+
175
188
  const recommendedRows = results
176
189
  .filter((r) => r.isRecommended && !r.isFavorite)
177
190
  .sort((a, b) => (b.recommendScore || 0) - (a.recommendScore || 0))
@@ -13,7 +13,8 @@
13
13
  * - Emoji-aware padding via padEndDisplay for aligned verdict/status cells
14
14
  * - Viewport clipping with above/below indicators
15
15
  * - Smart badges (mode, tier filter, origin filter)
16
- * - Install-endpoints shortcut surfaced directly in the footer hints
16
+ * - Favorites mode hint surfaced directly in footer hints (`Y`)
17
+ * - High-visibility active text-filter banner with one-key clear action (`X`)
17
18
  * - Full-width red outdated-version banner when a newer npm release is known
18
19
  * - Distinct auth-failure vs missing-key health labels so configured providers stay honest
19
20
  *
@@ -66,7 +67,7 @@ export const PROVIDER_COLOR = new Proxy({}, {
66
67
  })
67
68
 
68
69
  // ─── renderTable: mode param controls footer hint text (opencode vs openclaw) ─────────
69
- export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true) {
70
+ export function renderTable(results, pendingPings, frame, cursor = null, sortColumn = 'avg', sortDirection = 'asc', pingInterval = PING_INTERVAL, lastPingTime = Date.now(), mode = 'opencode', tierFilterMode = 0, scrollOffset = 0, terminalRows = 0, terminalCols = 0, originFilterMode = 0, legacyStatus = null, pingMode = 'normal', pingModeSource = 'auto', hideUnconfiguredModels = false, widthWarningStartedAt = null, widthWarningDismissed = false, widthWarningShowCount = 0, settingsUpdateState = 'idle', settingsUpdateLatestVersion = null, legacyFlag = false, startupLatestVersion = null, versionAlertsEnabled = true, favoritesPinnedAndSticky = false, customTextFilter = null) {
70
71
  // 📖 Filter out hidden models for display
71
72
  const visibleResults = results.filter(r => !r.hidden)
72
73
 
@@ -237,7 +238,9 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
237
238
  }
238
239
 
239
240
  // 📖 Sort models using the shared helper
240
- const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection)
241
+ const sorted = sortResultsWithPinnedFavorites(visibleResults, sortColumn, sortDirection, {
242
+ pinFavorites: favoritesPinnedAndSticky,
243
+ })
241
244
 
242
245
  const lines = [
243
246
  ` ${themeColors.accentBold(`🚀 free-coding-models v${LOCAL_VERSION}`)}${modeBadge}${pingControlBadge}${tierBadge}${originBadge}${chalk.reset('')} ` +
@@ -339,6 +342,7 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
339
342
  }
340
343
 
341
344
  // 📖 Viewport clipping: only render models that fit on screen
345
+ const hasCustomFilter = typeof customTextFilter === 'string' && customTextFilter.trim().length > 0
342
346
  const extraFooterLines = versionStatus.isOutdated ? 1 : 0
343
347
  const vp = calculateViewport(terminalRows, scrollOffset, sorted.length, extraFooterLines)
344
348
  const paintSweScore = (score, paddedText) => {
@@ -618,10 +622,14 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
618
622
  // 📖 states are obvious even when the user misses the smaller header badges.
619
623
  const configuredBadgeBg = getTheme() === 'dark' ? [52, 120, 88] : [195, 234, 206]
620
624
  const activeHotkey = (keyLabel, text, bg) => themeColors.badge(`${keyLabel}${text}`, bg, getReadableTextRgb(bg))
625
+ const favoritesModeBg = favoritesPinnedAndSticky ? [157, 122, 48] : [95, 95, 95]
626
+ const favoritesModeLabel = favoritesPinnedAndSticky ? ' Favorites Pinned' : ' Favorites Normal'
621
627
  // 📖 Line 1: core navigation + filtering shortcuts
622
628
  lines.push(
623
629
  ' ' + hotkey('F', ' Toggle Favorite') +
624
630
  themeColors.dim(` • `) +
631
+ activeHotkey('Y', favoritesModeLabel, favoritesModeBg) +
632
+ themeColors.dim(` • `) +
625
633
  (tierFilterMode > 0
626
634
  ? activeHotkey('T', ` Tier (${activeTierLabel})`, getTierRgb(activeTierLabel))
627
635
  : hotkey('T', ' Tier')) +
@@ -673,9 +681,29 @@ export function renderTable(results, pendingPings, frame, cursor = null, sortCol
673
681
  lines.push(chalk.bgRed.white.bold(paddedBanner))
674
682
  }
675
683
 
676
- // 📖 Final footer line: changelog shortcut + exit hint (replaces the old proxy notice).
684
+ // 📖 Final footer line: changelog + optional active text-filter badge + exit hint.
685
+ let filterBadge = ''
686
+ if (hasCustomFilter) {
687
+ const normalizedFilter = customTextFilter.trim().replace(/\s+/g, ' ')
688
+ const filterPrefix = 'X Disable filter: "'
689
+ const filterSuffix = '"'
690
+ const separatorPlain = ' • '
691
+ const baseFooterPlain = ' N Changelog' + separatorPlain + 'Ctrl+C Exit'
692
+ const baseBadgeWidth = displayWidth(` ${filterPrefix}${filterSuffix} `)
693
+ const availableFilterWidth = terminalCols > 0
694
+ ? Math.max(8, terminalCols - displayWidth(baseFooterPlain) - displayWidth(separatorPlain) - baseBadgeWidth)
695
+ : normalizedFilter.length
696
+ const visibleFilter = normalizedFilter.length > availableFilterWidth
697
+ ? `${normalizedFilter.slice(0, Math.max(3, availableFilterWidth - 3))}...`
698
+ : normalizedFilter
699
+ filterBadge = chalk.bgYellow.black.bold(` ${filterPrefix}${visibleFilter}${filterSuffix} `)
700
+ }
701
+
677
702
  lines.push(
678
703
  ' ' + themeColors.hotkey('N') + themeColors.dim(' Changelog') +
704
+ (filterBadge
705
+ ? themeColors.dim(' • ') + filterBadge
706
+ : '') +
679
707
  themeColors.dim(' • ') +
680
708
  themeColors.dim('Ctrl+C Exit')
681
709
  )
package/src/utils.js CHANGED
@@ -220,7 +220,7 @@ export const getStabilityScore = (r) => {
220
220
  //
221
221
  // 📖 Supported columns in the sorter.
222
222
  // 📖 Most map directly to visible TUI sort hotkeys; `tier` remains available internally
223
- // 📖 even though the live TUI now reserves `Y` for the install-endpoints flow.
223
+ // 📖 while `Y` is used by the live UI for favorites display mode.
224
224
  // - 'rank' (R key) — original index from sources.js
225
225
  // - 'tier' (internal) — tier hierarchy (S+ first, C last)
226
226
  // - 'origin' (O key) — provider name (all NIM for now, future-proofed)