free-coding-models 0.3.25 → 0.3.26

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,11 +1,56 @@
1
1
  # Changelog
2
2
  ---
3
3
 
4
+ ## [0.3.26] - 2026-03-27
5
+
6
+ ### Added
7
+ - **Groq**: Added Compound + Compound Mini; fixed Llama 4 Scout context (10M)
8
+ - **OpenRouter**: Added MiniMax M2.5, Nemotron 3 Super, Hermes 3 405B, Gemma 3n E4B
9
+ - **HuggingFace**: Replaced invalid DeepSeek-V3-Coder + outdated starcoder2-15b with DeepSeek V3 0324 + Qwen2.5 Coder 32B
10
+ - **Replicate**: Replaced CodeLlama 70B (2023) with DeepSeek V3 0324 + Llama 3.3 70B
11
+ - **Cloudflare**: Added Kimi K2.5, GLM-4.7-Flash, Llama 4 Scout, Nemotron 3 Super, Qwen3 30B MoE
12
+ - **Scaleway**: Added Qwen3.5 400B VLM + Mistral Large 675B
13
+ - **DeepInfra**: Replaced Mixtral Code with Nemotron 3 Super + DeepSeek V3 0324 + Qwen3 235B
14
+ - **Fireworks**: Added Llama 4 Maverick + Qwen3 235B
15
+ - **Hyperbolic**: Added Qwen3 80B Thinking variant
16
+ - **Together AI**: Added Qwen3.5 400B VLM, MiniMax M2.5, GLM-5
17
+
18
+ ### Changed
19
+ - **Rovo Dev CLI**: Updated Sonnet 4 → Sonnet 4.6, added Opus 4.6
20
+ - **Groq**: Removed 4 deprecated models (R1 Distill 70B, QwQ 32B, Kimi K2, Maverick)
21
+ - **OpenRouter**: Updated context sizes for multiple models
22
+
4
23
  ## [0.3.25] - 2026-03-19
5
24
 
25
+ ### Added
26
+ - **Installed Models Manager** — View, launch, and disable models configured in external tools (Goose, Crush, Aider, Qwen, Pi, OpenHands, Amp)
27
+ - Access via Command Palette (Ctrl+P) → "Installed models"
28
+ - Scans all supported tool configs automatically on opening
29
+ - Displays all models per tool (e.g., Crush shows both large and small models)
30
+ - Actions: Launch (Enter), Disable (D) with backup, Reinstall (R)
31
+ - Soft delete: Comments out model entries and saves backups to ~/.free-coding-models-backups.json
32
+ - **Full mouse support for the TUI** — Click, right-click, double-click, and scroll work throughout the interface
33
+ - **Click column headers** — Sort by any column (click Rank, Tier, SWE%, CTX, Model, Provider, etc.)
34
+ - **Click model rows** — Move cursor to any model (left-click)
35
+ - **Right-click model rows** — Toggle favorite (same as F key)
36
+ - **Double-click model rows** — Select model and launch (same as Enter)
37
+ - **Mouse wheel** — Scroll through the main table, overlays (Settings, Help, Changelog), and command palette results
38
+ - **Click CLI Tools header** — Cycle through tool modes (same as Z key)
39
+ - **Click Tier header** — Cycle through tier filters (same as T key)
40
+ - **Click footer hotkeys** — Trigger any visible hotkey from the footer
41
+ - **Command palette click** — Click inside to select items, double-click to confirm; click outside to close
42
+ - **Recommend questionnaire click** — Click on option rows to select, double-click to confirm
43
+ - **Mouse unit tests** — 46 new tests covering SGR sequence parsing, double-click detection, modifiers, and COLUMN_SORT_MAP validation
44
+
6
45
  ### Changed
7
- - **Removed "CLI Tools" column** — The compat emoji column has been removed from the TUI table, freeing ~22 characters of horizontal space for other columns
8
- - **Cleaner table layout** — Responsive column hiding no longer needs to drop the compat column first on narrow terminals
46
+ - **CLI Tools column redesigned** — Renamed from "Compatible with" to "CLI Tools", with left-aligned emoji display (compatible tools packed left instead of fixed slot positions)
47
+ - **Sort arrow overflow fixed** — SWE%, CTX, Stability, and Uptime columns now properly fit within their widths when sorted (arrow now `↑SWE%` instead of `↑ SWE%`)
48
+ - **Mouse sequence suppression** — SGR mouse sequences no longer leak into keypress handlers (prevents spurious sort/filter triggers when clicking)
49
+
50
+ ### Fixed
51
+ - **Command palette scroll leak** — Mouse wheel no longer injects raw SGR sequence bytes into the command palette text input
52
+ - **Double-action on model click** — Clicking a model row now only moves the cursor; it no longer simultaneously triggers column sorting
53
+ - **Mouse event listener order** — Fixed race condition where readline emitted keypress events before mouse data was processed
9
54
 
10
55
  ## [0.3.24] - 2026-03-19
11
56
 
package/README.md CHANGED
@@ -229,6 +229,8 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
229
229
 
230
230
  ## ⌨️ TUI Keys
231
231
 
232
+ ### Keyboard
233
+
232
234
  | Key | Action |
233
235
  |-----|--------|
234
236
  | `↑↓` | Navigate models |
@@ -251,6 +253,22 @@ When a tool mode is active (via `Z`), models incompatible with that tool are hig
251
253
  | `K` | Help overlay |
252
254
  | `Ctrl+C` | Exit |
253
255
 
256
+ ### Mouse
257
+
258
+ | Action | Result |
259
+ |--------|--------|
260
+ | **Click column header** | Sort by that column |
261
+ | **Click Tier header** | Cycle tier filter |
262
+ | **Click CLI Tools header** | Cycle tool mode |
263
+ | **Click model row** | Move cursor to model |
264
+ | **Double-click model row** | Select and launch model |
265
+ | **Right-click model row** | Toggle favorite |
266
+ | **Scroll wheel** | Navigate table / overlays / palette |
267
+ | **Click footer hotkey** | Trigger that action |
268
+ | **Click command palette item** | Select item (double-click to confirm) |
269
+ | **Click recommend option** | Select option (double-click to confirm) |
270
+ | **Click outside modal** | Close command palette |
271
+
254
272
  → **[Stability score & column reference](./docs/stability.md)**
255
273
 
256
274
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "free-coding-models",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
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/sources.js CHANGED
@@ -99,15 +99,13 @@ export const nvidiaNim = [
99
99
  // 📖 Free API keys available at https://console.groq.com/keys
100
100
  export const groq = [
101
101
  ['llama-3.3-70b-versatile', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
102
- ['meta-llama/llama-4-scout-17b-16e-preview', 'Llama 4 Scout', 'A', '44.0%', '10M'],
103
- ['meta-llama/llama-4-maverick-17b-128e-preview', 'Llama 4 Maverick', 'S', '62.0%', '1M'],
104
- ['deepseek-r1-distill-llama-70b', 'R1 Distill 70B', 'A', '43.9%', '128k'],
105
- ['qwen-qwq-32b', 'QwQ 32B', 'A+', '50.0%', '131k'],
106
- ['moonshotai/kimi-k2-instruct', 'Kimi K2 Instruct', 'S', '65.8%', '131k'],
102
+ ['meta-llama/llama-4-scout-17b-16e-preview', 'Llama 4 Scout', 'A', '44.0%', '131k'],
107
103
  ['llama-3.1-8b-instant', 'Llama 3.1 8B', 'B', '28.8%', '128k'],
108
104
  ['openai/gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
109
105
  ['openai/gpt-oss-20b', 'GPT OSS 20B', 'A', '42.0%', '128k'],
110
106
  ['qwen/qwen3-32b', 'Qwen3 32B', 'A+', '50.0%', '131k'],
107
+ ['groq/compound', 'Groq Compound', 'A', '45.0%', '131k'],
108
+ ['groq/compound-mini', 'Groq Compound Mini', 'B+', '32.0%', '131k'],
111
109
  ]
112
110
 
113
111
  // 📖 Cerebras source - https://cloud.cerebras.ai
@@ -158,36 +156,43 @@ export const sambanova = [
158
156
  // 📖 API keys at https://openrouter.ai/keys
159
157
  export const openrouter = [
160
158
  ['qwen/qwen3-coder:free', 'Qwen3 Coder 480B', 'S+', '70.6%', '262k'],
161
- ['z-ai/glm-4.5-air:free', 'GLM 4.5 Air', 'S+', '72.0%', '128k'],
162
- ['google/gemma-3-27b-it:free', 'Gemma 3 27B', 'B', '22.0%', '128k'],
159
+ ['minimax/minimax-m2.5:free', 'MiniMax M2.5', 'S+', '74.0%', '197k'],
160
+ ['z-ai/glm-4.5-air:free', 'GLM 4.5 Air', 'S+', '72.0%', '131k'],
163
161
  ['stepfun/step-3.5-flash:free', 'Step 3.5 Flash', 'S+', '74.4%', '256k'],
164
- ['qwen/qwen3-next-80b-a3b-instruct:free', 'Qwen3 80B Instruct', 'S', '65.0%', '128k'],
165
- ['openai/gpt-oss-120b:free', 'GPT OSS 120B', 'S', '60.0%', '128k'],
166
- ['openai/gpt-oss-20b:free', 'GPT OSS 20B', 'A', '42.0%', '128k'],
162
+ ['nvidia/nemotron-3-super-120b-a12b:free', 'Nemotron 3 Super', 'A+', '56.0%', '262k'],
163
+ ['qwen/qwen3-next-80b-a3b-instruct:free', 'Qwen3 80B Instruct', 'S', '65.0%', '131k'],
164
+ ['nousresearch/hermes-3-llama-3.1-405b:free', 'Hermes 3 405B', 'A', '44.0%', '131k'],
165
+ ['openai/gpt-oss-120b:free', 'GPT OSS 120B', 'S', '60.0%', '131k'],
166
+ ['openai/gpt-oss-20b:free', 'GPT OSS 20B', 'A', '42.0%', '131k'],
167
167
  ['nvidia/nemotron-3-nano-30b-a3b:free', 'Nemotron Nano 30B', 'A', '43.0%', '128k'],
168
- ['meta-llama/llama-3.3-70b-instruct:free', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
168
+ ['meta-llama/llama-3.3-70b-instruct:free', 'Llama 3.3 70B', 'A-', '39.5%', '131k'],
169
169
  ['mistralai/mistral-small-3.1-24b-instruct:free', 'Mistral Small 3.1', 'B+', '30.0%', '128k'],
170
- ['google/gemma-3-12b-it:free', 'Gemma 3 12B', 'C', '15.0%', '128k'],
170
+ ['google/gemma-3-27b-it:free', 'Gemma 3 27B', 'B', '22.0%', '131k'],
171
+ ['google/gemma-3-12b-it:free', 'Gemma 3 12B', 'C', '15.0%', '131k'],
172
+ ['google/gemma-3n-e4b-it:free', 'Gemma 3n E4B', 'C', '10.0%', '8k'],
171
173
  ]
172
174
 
173
175
  // 📖 Hugging Face Inference source - https://huggingface.co
174
176
  // 📖 OpenAI-compatible endpoint via router.huggingface.co/v1
175
177
  // 📖 Free monthly credits on developer accounts (~$0.10) — token at https://huggingface.co/settings/tokens
176
178
  export const huggingface = [
177
- ['deepseek-ai/DeepSeek-V3-Coder', 'DeepSeek V3 Coder', 'S', '62.0%', '128k'],
178
- ['bigcode/starcoder2-15b', 'StarCoder2 15B', 'B', '25.0%', '16k'],
179
+ ['deepseek-ai/DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
180
+ ['Qwen/Qwen2.5-Coder-32B-Instruct', 'Qwen2.5 Coder 32B', 'A', '46.0%', '32k'],
179
181
  ]
180
182
 
181
183
  // 📖 Replicate source - https://replicate.com
182
184
  // 📖 Uses predictions endpoint (not OpenAI chat-completions) with token auth
183
185
  export const replicate = [
184
- ['codellama/CodeLlama-70b-Instruct-hf', 'CodeLlama 70B', 'A-', '39.0%', '16k'],
186
+ ['deepseek-ai/DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
187
+ ['meta/llama-3.3-70b-instruct', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
185
188
  ]
186
189
 
187
190
  // 📖 DeepInfra source - https://deepinfra.com
188
191
  // 📖 OpenAI-compatible endpoint: https://api.deepinfra.com/v1/openai/chat/completions
189
192
  export const deepinfra = [
190
- ['mistralai/Mixtral-8x22B-Instruct-v0.1', 'Mixtral Code', 'B+', '32.0%', '64k'],
193
+ ['nvidia/Nemotron-3-Super', 'Nemotron 3 Super', 'A+', '56.0%', '128k'],
194
+ ['deepseek-ai/DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
195
+ ['Qwen/Qwen3-235B-A22B', 'Qwen3 235B', 'S+', '70.0%', '128k'],
191
196
  ['meta-llama/Meta-Llama-3.1-70B-Instruct', 'Llama 3.1 70B', 'A-', '39.5%', '128k'],
192
197
  ]
193
198
 
@@ -197,6 +202,8 @@ export const deepinfra = [
197
202
  export const fireworks = [
198
203
  ['accounts/fireworks/models/deepseek-v3', 'DeepSeek V3', 'S', '62.0%', '128k'],
199
204
  ['accounts/fireworks/models/deepseek-r1', 'DeepSeek R1', 'S', '61.0%', '128k'],
205
+ ['accounts/fireworks/models/llama4-maverick-instruct-basic', 'Llama 4 Maverick', 'S', '62.0%', '1M'],
206
+ ['accounts/fireworks/models/qwen3-235b-a22b', 'Qwen3 235B', 'S+', '70.0%', '128k'],
200
207
  ]
201
208
 
202
209
  // 📖 Mistral Codestral source - https://codestral.mistral.ai
@@ -215,6 +222,7 @@ export const hyperbolic = [
215
222
  ['openai/gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
216
223
  ['Qwen/Qwen3-235B-A22B', 'Qwen3 235B', 'S+', '70.0%', '128k'],
217
224
  ['qwen/qwen3-next-80b-a3b-instruct', 'Qwen3 80B Instruct', 'S', '65.0%', '128k'],
225
+ ['Qwen/Qwen3-Next-80B-A3B-Thinking', 'Qwen3 80B Thinking', 'S', '68.0%', '128k'],
218
226
  ['deepseek-ai/DeepSeek-V3-0324', 'DeepSeek V3 0324', 'S', '62.0%', '128k'],
219
227
  ['Qwen/Qwen2.5-Coder-32B-Instruct', 'Qwen2.5 Coder 32B', 'A', '46.0%', '32k'],
220
228
  ['meta-llama/Llama-3.3-70B-Instruct', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
@@ -224,9 +232,10 @@ export const hyperbolic = [
224
232
  // 📖 Scaleway source - https://console.scaleway.com
225
233
  // 📖 1M free tokens — API keys at https://console.scaleway.com/iam/api-keys
226
234
  export const scaleway = [
227
- ['devstral-2-123b-instruct-2512', 'Devstral 2 123B', 'S+', '72.2%', '256k'],
235
+ ['devstral-2-123b-instruct-2512', 'Devstral 2 123B', 'S+', '72.2%', '256k'],
236
+ ['qwen3.5-397b-a17b', 'Qwen3.5 400B VLM', 'S', '68.0%', '250k'],
237
+ ['mistral/mistral-large-3-675b-instruct-2512', 'Mistral Large 675B', 'A+', '58.0%', '250k'],
228
238
  ['qwen3-235b-a22b-instruct-2507', 'Qwen3 235B', 'S+', '70.0%', '128k'],
229
- ['gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
230
239
  ['qwen3-coder-30b-a3b-instruct', 'Qwen3 Coder 30B', 'A+', '55.0%', '32k'],
231
240
  ['llama-3.3-70b-instruct', 'Llama 3.3 70B', 'A-', '39.5%', '128k'],
232
241
  ['deepseek-r1-distill-llama-70b', 'R1 Distill 70B', 'A', '43.9%', '128k'],
@@ -272,6 +281,9 @@ export const siliconflow = [
272
281
  // 📖 Credits/promotions vary by account and region; verify current quota in console.
273
282
  export const together = [
274
283
  ['moonshotai/Kimi-K2.5', 'Kimi K2.5', 'S+', '76.8%', '128k'],
284
+ ['Qwen/Qwen3.5-397B-A17B', 'Qwen3.5 400B VLM', 'S', '68.0%', '250k'],
285
+ ['MiniMaxAI/MiniMax-M2.5', 'MiniMax M2.5', 'S+', '80.2%', '200k'],
286
+ ['zai-org/GLM-5', 'GLM-5', 'S+', '77.8%', '128k'],
275
287
  ['Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8', 'Qwen3 Coder 480B', 'S+', '70.6%', '256k'],
276
288
  ['deepseek-ai/DeepSeek-V3.1', 'DeepSeek V3.1', 'S', '62.0%', '128k'],
277
289
  ['deepseek-ai/DeepSeek-R1', 'DeepSeek R1', 'S', '61.0%', '128k'],
@@ -285,7 +297,12 @@ export const together = [
285
297
  // 📖 https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/v1/chat/completions
286
298
  // 📖 Free plan includes daily neuron quota and provider-level request limits.
287
299
  export const cloudflare = [
300
+ ['@cf/moonshotai/kimi-k2.5', 'Kimi K2.5', 'S+', '76.8%', '256k'],
301
+ ['@cf/zhipu/glm-4.7-flash', 'GLM-4.7-Flash', 'S', '59.2%', '131k'],
288
302
  ['@cf/openai/gpt-oss-120b', 'GPT OSS 120B', 'S', '60.0%', '128k'],
303
+ ['@cf/meta/llama-4-scout-17b-16e-instruct', 'Llama 4 Scout', 'A', '44.0%', '131k'],
304
+ ['@cf/nvidia/nemotron-3-120b-a12b', 'Nemotron 3 Super', 'A+', '56.0%', '128k'],
305
+ ['@cf/qwen/qwen3-30b-a3b-fp8', 'Qwen3 30B MoE', 'A', '45.0%', '128k'],
289
306
  ['@cf/qwen/qwen2.5-coder-32b-instruct', 'Qwen2.5 Coder 32B', 'A', '46.0%', '32k'],
290
307
  ['@cf/deepseek-ai/deepseek-r1-distill-qwen-32b', 'R1 Distill 32B', 'A', '43.9%', '128k'],
291
308
  ['@cf/openai/gpt-oss-20b', 'GPT OSS 20B', 'A', '42.0%', '128k'],
@@ -350,7 +367,8 @@ export const iflow = [
350
367
  // 📖 Free tier: 5M tokens/day (beta) - Claude Sonnet 4 (72.7% SWE-bench)
351
368
  // 📖 Requires Atlassian account + Rovo Dev activated on your site
352
369
  export const rovo = [
353
- ['anthropic/claude-sonnet-4', 'Claude Sonnet 4 🆕', 'S+', '72.7%', '200k'],
370
+ ['anthropic/claude-sonnet-4.6', 'Claude Sonnet 4.6', 'S+', '75.0%', '200k'],
371
+ ['anthropic/claude-opus-4.6', 'Claude Opus 4.6', 'S+', '80.0%', '200k'],
354
372
  ]
355
373
 
356
374
  // 📖 Gemini CLI source - https://github.com/google-gemini/gemini-cli
package/src/app.js CHANGED
@@ -471,6 +471,12 @@ export async function runApp(cliArgs, config) {
471
471
  changelogPhase: 'index', // 📖 'index' (all versions) | 'details' (specific version)
472
472
  changelogCursor: 0, // 📖 Selected row in index phase
473
473
  changelogSelectedVersion: null, // 📖 Which version to show details for
474
+ // 📖 Installed Models overlay state (Command Palette → Installed models)
475
+ installedModelsOpen: false, // 📖 Whether the installed models overlay is active
476
+ installedModelsCursor: 0, // 📖 Selected row (tool or model)
477
+ installedModelsScrollOffset: 0, // 📖 Vertical scroll offset for overlay viewport
478
+ installedModelsData: [], // 📖 Cached scan results
479
+ installedModelsErrorMsg: null, // 📖 Error or status message
474
480
  // 📖 Custom text filter (Ctrl+P palette → type text → Enter). Ephemeral — not saved to config.
475
481
  customTextFilter: null, // 📖 Active free-text filter string (null = off). Matches model name, ctx, provider key/name.
476
482
  }
@@ -951,7 +957,7 @@ export async function runApp(cliArgs, config) {
951
957
  refreshAutoPingMode()
952
958
  state.frame++
953
959
  // 📖 Cache visible+sorted models each frame so Enter handler always matches the display
954
- if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.incompatibleFallbackOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.commandPaletteOpen) {
960
+ if (!state.settingsOpen && !state.installEndpointsOpen && !state.toolInstallPromptOpen && !state.incompatibleFallbackOpen && !state.recommendOpen && !state.feedbackOpen && !state.changelogOpen && !state.installedModelsOpen && !state.commandPaletteOpen) {
955
961
  const visible = state.results.filter(r => !r.hidden)
956
962
  state.visibleSorted = sortResultsWithPinnedFavorites(visible, state.sortColumn, state.sortDirection, {
957
963
  pinFavorites: state.favoritesPinnedAndSticky,
@@ -1034,6 +1040,8 @@ export async function runApp(cliArgs, config) {
1034
1040
  ? overlays.renderInstallEndpoints()
1035
1041
  : state.toolInstallPromptOpen
1036
1042
  ? overlays.renderToolInstallPrompt()
1043
+ : state.installedModelsOpen
1044
+ ? overlays.renderInstalledModels()
1037
1045
  : state.incompatibleFallbackOpen
1038
1046
  ? overlays.renderIncompatibleFallback()
1039
1047
  : state.commandPaletteOpen
@@ -203,6 +203,7 @@ const BASE_COMMAND_TREE = [
203
203
  { id: 'open-feedback', label: 'Feedback', shortcut: 'I', icon: '📝', type: 'page', description: 'Report bugs or requests', keywords: ['feedback', 'bug', 'request'] },
204
204
  { id: 'open-recommend', label: 'Smart recommend', shortcut: 'Q', icon: '🎯', type: 'page', description: 'Find best model for task', keywords: ['recommend', 'best model'] },
205
205
  { id: 'open-install-endpoints', label: 'Install endpoints', icon: '🔌', type: 'page', description: 'Install provider catalogs', keywords: ['install', 'endpoints', 'providers'] },
206
+ { id: 'open-installed-models', label: 'Installed models', icon: '🗂️', type: 'page', description: 'View models configured in tools', keywords: ['installed', 'models', 'configured', 'tools', 'manager', 'goose', 'crush', 'aider'] },
206
207
  ]
207
208
 
208
209
  /**
@@ -0,0 +1,636 @@
1
+ /**
2
+ * @file src/installed-models-manager.js
3
+ * @description Scan, parse, and manage models configured in external tool configs.
4
+ *
5
+ * @details
6
+ * 📖 This module provides functions to:
7
+ * - Scan all supported tool configs for installed models
8
+ * - Parse tool-specific config files (YAML, JSON)
9
+ * - Soft-delete models with backup to ~/.free-coding-models-backups.json
10
+ * - Launch tools with selected models
11
+ * - Reinstall FCM endpoints for providers
12
+ *
13
+ * 📖 Supported tools:
14
+ * - Goose (~/.config/goose/config.yaml + custom_providers/*.json)
15
+ * - Crush (~/.config/crush/crush.json)
16
+ * - Aider (~/.aider.conf.yml)
17
+ * - Qwen (~/.qwen/settings.json)
18
+ * - Pi (~/.pi/agent/models.json + settings.json)
19
+ * - OpenHands (~/.fcm-openhands-env)
20
+ * - Amp (~/.config/amp/settings.json)
21
+ *
22
+ * 📖 Backup system:
23
+ * - Disabled models are saved to ~/.free-coding-models-backups.json
24
+ * - Each entry includes: toolMode, modelId, originalConfig, configPath, disabledAt
25
+ *
26
+ * @functions
27
+ * → scanAllToolConfigs — Scan all tool configs and return structured results
28
+ * → parseToolConfig — Parse a specific tool's config file
29
+ * → parseGooseConfig — Parse Goose YAML config
30
+ * → parseCrushConfig — Parse Crush JSON config
31
+ * → parseAiderConfig — Parse Aider YAML config
32
+ * → parseQwenConfig — Parse Qwen JSON config
33
+ * → parsePiConfig — Parse Pi JSON configs
34
+ * → parseOpenHandsConfig — Parse OpenHands env file
35
+ * → parseAmpConfig — Parse Amp JSON config
36
+ * → softDeleteModel — Remove model from config with backup
37
+ * → launchToolWithModel — Launch tool with specific model
38
+ * → reinstallEndpoint — Reinstall FCM endpoint for provider
39
+ *
40
+ * @exports scanAllToolConfigs, softDeleteModel, launchToolWithModel, reinstallEndpoint
41
+ *
42
+ * @see src/tool-launchers.js — for launch functions
43
+ * @see src/endpoint-installer.js — for reinstall logic
44
+ */
45
+
46
+ import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'
47
+ import { homedir } from 'node:os'
48
+ import { join, dirname } from 'node:path'
49
+ import { sources } from '../sources.js'
50
+
51
+ const BACKUP_PATH = join(homedir(), '.free-coding-models-backups.json')
52
+
53
+ /**
54
+ * 📖 Get tool config paths
55
+ */
56
+ function getToolConfigPaths(homeDir = homedir()) {
57
+ return {
58
+ goose: join(homeDir, '.config', 'goose', 'config.yaml'),
59
+ crush: join(homeDir, '.config', 'crush', 'crush.json'),
60
+ aider: join(homeDir, '.aider.conf.yml'),
61
+ qwen: join(homeDir, '.qwen', 'settings.json'),
62
+ piModels: join(homeDir, '.pi', 'agent', 'models.json'),
63
+ piSettings: join(homeDir, '.pi', 'agent', 'settings.json'),
64
+ openHands: join(homeDir, '.fcm-openhands-env'),
65
+ amp: join(homeDir, '.config', 'amp', 'settings.json'),
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 📖 Simple YAML parser for Goose and Aider configs
71
+ * Handles basic key: value and multiline strings
72
+ */
73
+ function parseSimpleYaml(filePath) {
74
+ if (!existsSync(filePath)) return null
75
+ try {
76
+ const content = readFileSync(filePath, 'utf8')
77
+ const result = {}
78
+ let currentKey = null
79
+
80
+ for (const line of content.split('\n')) {
81
+ const trimmed = line.trim()
82
+ if (!trimmed || trimmed.startsWith('#')) continue
83
+
84
+ const colonIndex = trimmed.indexOf(':')
85
+ if (colonIndex === -1) {
86
+ if (currentKey && result[currentKey] !== undefined) {
87
+ result[currentKey] += '\n' + trimmed
88
+ }
89
+ continue
90
+ }
91
+
92
+ currentKey = trimmed.slice(0, colonIndex).trim()
93
+ const value = trimmed.slice(colonIndex + 1).trim()
94
+
95
+ if (value === '' || value === '|') {
96
+ result[currentKey] = ''
97
+ } else if (value.startsWith('"') || value.startsWith("'")) {
98
+ result[currentKey] = value.slice(1, -1)
99
+ } else {
100
+ result[currentKey] = value
101
+ }
102
+ }
103
+
104
+ return result
105
+ } catch (err) {
106
+ return null
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 📖 Parse Goose config for GOOSE_MODEL
112
+ */
113
+ function parseGooseConfig(paths = getToolConfigPaths()) {
114
+ const configPath = paths.goose
115
+ if (!existsSync(configPath)) {
116
+ return { isValid: false, models: [], configPath }
117
+ }
118
+
119
+ try {
120
+ const yaml = parseSimpleYaml(configPath)
121
+ if (!yaml) {
122
+ return { isValid: false, models: [], configPath }
123
+ }
124
+
125
+ const gooseModel = yaml['GOOSE_MODEL']
126
+ const gooseProvider = yaml['GOOSE_PROVIDER']
127
+
128
+ const models = []
129
+ if (gooseModel) {
130
+ models.push({
131
+ modelId: gooseModel,
132
+ label: gooseModel,
133
+ tier: '-',
134
+ sweScore: '-',
135
+ providerKey: 'external',
136
+ isExternal: true,
137
+ canLaunch: true,
138
+ })
139
+ }
140
+
141
+ return {
142
+ isValid: true,
143
+ hasManagedMarker: yaml['GOOSE_PROVIDER']?.startsWith('fcm-'),
144
+ models,
145
+ configPath,
146
+ }
147
+ } catch (err) {
148
+ return { isValid: false, models: [], configPath }
149
+ }
150
+ }
151
+
152
+ /**
153
+ * 📖 Parse Crush config for models.large/small
154
+ */
155
+ function parseCrushConfig(paths = getToolConfigPaths()) {
156
+ const configPath = paths.crush
157
+ if (!existsSync(configPath)) {
158
+ return { isValid: false, models: [], configPath }
159
+ }
160
+
161
+ try {
162
+ const content = readFileSync(configPath, 'utf8')
163
+ const config = JSON.parse(content)
164
+
165
+ const models = []
166
+ // Extract models from providers section
167
+ if (config.providers) {
168
+ for (const providerKey in config.providers) {
169
+ const provider = config.providers[providerKey]
170
+ if (provider.models) {
171
+ for (const model of provider.models) {
172
+ models.push({
173
+ modelId: model.id,
174
+ label: model.name || model.id,
175
+ tier: '-',
176
+ sweScore: '-',
177
+ providerKey: 'external',
178
+ isExternal: true,
179
+ canLaunch: true,
180
+ })
181
+ }
182
+ }
183
+ }
184
+ }
185
+
186
+ // Extract models from models section (large/small)
187
+ if (config.models?.large?.model) {
188
+ models.push({
189
+ modelId: config.models.large.model,
190
+ label: `${config.models.large.model} (large)`,
191
+ tier: '-',
192
+ sweScore: '-',
193
+ providerKey: 'external',
194
+ isExternal: true,
195
+ canLaunch: true,
196
+ })
197
+ }
198
+ if (config.models?.small?.model) {
199
+ models.push({
200
+ modelId: config.models.small.model,
201
+ label: `${config.models.small.model} (small)`,
202
+ tier: '-',
203
+ sweScore: '-',
204
+ providerKey: 'external',
205
+ isExternal: true,
206
+ canLaunch: true,
207
+ })
208
+ }
209
+ if (config.models?.small?.model) {
210
+ models.push({
211
+ modelId: config.models.small.model + '-small',
212
+ label: `${config.models.small.model} (small)`,
213
+ tier: '-',
214
+ sweScore: '-',
215
+ providerKey: 'external',
216
+ isExternal: true,
217
+ canLaunch: true,
218
+ })
219
+ }
220
+
221
+ return {
222
+ isValid: true,
223
+ hasManagedMarker: config.providers?.freeCodingModels !== undefined,
224
+ models,
225
+ configPath,
226
+ }
227
+ } catch (err) {
228
+ return { isValid: false, models: [], configPath }
229
+ }
230
+ }
231
+
232
+ /**
233
+ * 📖 Parse Aider config for model
234
+ */
235
+ function parseAiderConfig(paths = getToolConfigPaths()) {
236
+ const configPath = paths.aider
237
+ if (!existsSync(configPath)) {
238
+ return { isValid: false, models: [], configPath }
239
+ }
240
+
241
+ try {
242
+ const yaml = parseSimpleYaml(configPath)
243
+ if (!yaml) {
244
+ return { isValid: false, models: [], configPath }
245
+ }
246
+
247
+ const models = []
248
+ const aiderModel = yaml['model']
249
+ if (aiderModel) {
250
+ const modelId = aiderModel.startsWith('openai/') ? aiderModel.slice(7) : aiderModel
251
+ models.push({
252
+ modelId,
253
+ label: modelId,
254
+ tier: '-',
255
+ sweScore: '-',
256
+ providerKey: 'external',
257
+ isExternal: true,
258
+ canLaunch: true,
259
+ })
260
+ }
261
+
262
+ return {
263
+ isValid: true,
264
+ hasManagedMarker: yaml['openai-api-base']?.includes('build.nvidia.com') || false,
265
+ models,
266
+ configPath,
267
+ }
268
+ } catch (err) {
269
+ return { isValid: false, models: [], configPath }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * 📖 Parse Qwen config for model
275
+ */
276
+ function parseQwenConfig(paths = getToolConfigPaths()) {
277
+ const configPath = paths.qwen
278
+ if (!existsSync(configPath)) {
279
+ return { isValid: false, models: [], configPath }
280
+ }
281
+
282
+ try {
283
+ const content = readFileSync(configPath, 'utf8')
284
+ const config = JSON.parse(content)
285
+
286
+ const models = []
287
+ if (config.model) {
288
+ models.push({
289
+ modelId: config.model,
290
+ label: config.model,
291
+ tier: '-',
292
+ sweScore: '-',
293
+ providerKey: 'external',
294
+ isExternal: true,
295
+ canLaunch: true,
296
+ })
297
+ }
298
+
299
+ return {
300
+ isValid: true,
301
+ hasManagedMarker: Array.isArray(config.modelProviders?.openai) && config.modelProviders.openai.length > 0,
302
+ models,
303
+ configPath,
304
+ }
305
+ } catch (err) {
306
+ return { isValid: false, models: [], configPath }
307
+ }
308
+ }
309
+
310
+ /**
311
+ * 📖 Parse Pi configs for defaultModel
312
+ */
313
+ function parsePiConfig(paths = getToolConfigPaths()) {
314
+ const settingsPath = paths.piSettings
315
+ if (!existsSync(settingsPath)) {
316
+ return { isValid: false, models: [], configPath: settingsPath }
317
+ }
318
+
319
+ try {
320
+ const content = readFileSync(settingsPath, 'utf8')
321
+ const config = JSON.parse(content)
322
+
323
+ const models = []
324
+ if (config.defaultModel && config.defaultProvider) {
325
+ models.push({
326
+ modelId: config.defaultModel,
327
+ label: config.defaultModel,
328
+ tier: '-',
329
+ sweScore: '-',
330
+ providerKey: 'external',
331
+ isExternal: true,
332
+ canLaunch: true,
333
+ })
334
+ }
335
+
336
+ return {
337
+ isValid: true,
338
+ hasManagedMarker: config.defaultProvider === 'freeCodingModels',
339
+ models,
340
+ configPath: settingsPath,
341
+ }
342
+ } catch (err) {
343
+ return { isValid: false, models: [], configPath: settingsPath }
344
+ }
345
+ }
346
+
347
+ /**
348
+ * 📖 Parse OpenHands env file for LLM_MODEL
349
+ */
350
+ function parseOpenHandsConfig(paths = getToolConfigPaths()) {
351
+ const configPath = paths.openHands
352
+ if (!existsSync(configPath)) {
353
+ return { isValid: false, models: [], configPath }
354
+ }
355
+
356
+ try {
357
+ const content = readFileSync(configPath, 'utf8')
358
+ const models = []
359
+
360
+ for (const line of content.split('\n')) {
361
+ const trimmed = line.trim()
362
+ if (trimmed.startsWith('export LLM_MODEL="') || trimmed.startsWith('export LLM_MODEL=\'')) {
363
+ const match = trimmed.match(/export LLM_MODEL=(["'])(.*?)\1/)
364
+ if (match) {
365
+ models.push({
366
+ modelId: match[2],
367
+ label: match[2],
368
+ tier: '-',
369
+ sweScore: '-',
370
+ providerKey: 'external',
371
+ isExternal: true,
372
+ canLaunch: true,
373
+ })
374
+ }
375
+ }
376
+ }
377
+
378
+ return {
379
+ isValid: true,
380
+ hasManagedMarker: content.includes('Managed by free-coding-models'),
381
+ models,
382
+ configPath,
383
+ }
384
+ } catch (err) {
385
+ return { isValid: false, models: [], configPath }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * 📖 Parse Amp config for amp.model
391
+ */
392
+ function parseAmpConfig(paths = getToolConfigPaths()) {
393
+ const configPath = paths.amp
394
+ if (!existsSync(configPath)) {
395
+ return { isValid: false, models: [], configPath }
396
+ }
397
+
398
+ try {
399
+ const content = readFileSync(configPath, 'utf8')
400
+ const config = JSON.parse(content)
401
+
402
+ const models = []
403
+ if (config['amp.model']) {
404
+ models.push({
405
+ modelId: config['amp.model'],
406
+ label: config['amp.model'],
407
+ tier: '-',
408
+ sweScore: '-',
409
+ providerKey: 'external',
410
+ isExternal: true,
411
+ canLaunch: true,
412
+ })
413
+ }
414
+
415
+ return {
416
+ isValid: true,
417
+ hasManagedMarker: config['amp.url']?.includes('build.nvidia.com') || false,
418
+ models,
419
+ configPath,
420
+ }
421
+ } catch (err) {
422
+ return { isValid: false, models: [], configPath }
423
+ }
424
+ }
425
+
426
+ /**
427
+ * 📖 Enhance model with metadata from sources.js
428
+ */
429
+ function enhanceModelMetadata(model) {
430
+ const modelId = model.modelId
431
+
432
+ for (const providerKey in sources) {
433
+ const provider = sources[providerKey]
434
+ for (const m of provider.models) {
435
+ if (m[0] === modelId) {
436
+ return {
437
+ ...model,
438
+ label: m[1],
439
+ tier: m[2],
440
+ sweScore: m[3],
441
+ providerKey,
442
+ isExternal: false,
443
+ }
444
+ }
445
+ }
446
+ }
447
+
448
+ return model
449
+ }
450
+
451
+ /**
452
+ * 📖 Parse a specific tool's config
453
+ */
454
+ export function parseToolConfig(toolMode, paths = getToolConfigPaths()) {
455
+ switch (toolMode) {
456
+ case 'goose':
457
+ return parseGooseConfig(paths)
458
+ case 'crush':
459
+ return parseCrushConfig(paths)
460
+ case 'aider':
461
+ return parseAiderConfig(paths)
462
+ case 'qwen':
463
+ return parseQwenConfig(paths)
464
+ case 'pi':
465
+ return parsePiConfig(paths)
466
+ case 'openhands':
467
+ return parseOpenHandsConfig(paths)
468
+ case 'amp':
469
+ return parseAmpConfig(paths)
470
+ default:
471
+ return { isValid: false, models: [], configPath: '' }
472
+ }
473
+ }
474
+
475
+ /**
476
+ * 📖 Scan all tool configs and return structured results
477
+ */
478
+ export function scanAllToolConfigs(paths = getToolConfigPaths()) {
479
+ const toolModes = ['goose', 'crush', 'aider', 'qwen', 'pi', 'openhands', 'amp']
480
+
481
+ return toolModes.map((toolMode) => {
482
+ const result = parseToolConfig(toolMode, paths)
483
+
484
+ return {
485
+ toolMode,
486
+ toolLabel: toolMode.charAt(0).toUpperCase() + toolMode.slice(1),
487
+ toolEmoji: getToolEmoji(toolMode),
488
+ ...result,
489
+ models: result.models.map(enhanceModelMetadata),
490
+ }
491
+ })
492
+ }
493
+
494
+ /**
495
+ * 📖 Get tool emoji
496
+ */
497
+ function getToolEmoji(toolMode) {
498
+ const emojis = {
499
+ goose: '🪿',
500
+ crush: '💘',
501
+ aider: '🛠',
502
+ qwen: '🐉',
503
+ pi: 'π',
504
+ openhands: '🤲',
505
+ amp: '⚡',
506
+ }
507
+ return emojis[toolMode] || '🧰'
508
+ }
509
+
510
+ /**
511
+ * 📖 Load backups from ~/.free-coding-models-backups.json
512
+ */
513
+ function loadBackups() {
514
+ if (!existsSync(BACKUP_PATH)) {
515
+ return { disabledModels: [] }
516
+ }
517
+ try {
518
+ const content = readFileSync(BACKUP_PATH, 'utf8')
519
+ return JSON.parse(content)
520
+ } catch (err) {
521
+ return { disabledModels: [] }
522
+ }
523
+ }
524
+
525
+ /**
526
+ * 📖 Save backups to ~/.free-coding-models-backups.json
527
+ */
528
+ function saveBackups(backups) {
529
+ const dir = dirname(BACKUP_PATH)
530
+ if (!existsSync(dir)) {
531
+ mkdirSync(dir, { recursive: true })
532
+ }
533
+ writeFileSync(BACKUP_PATH, JSON.stringify(backups, null, 2))
534
+ }
535
+
536
+ /**
537
+ * 📖 Soft-delete a model from tool config with backup
538
+ */
539
+ export function softDeleteModel(toolMode, modelId, paths = getToolConfigPaths()) {
540
+ const configPath = paths[toolMode === 'pi' ? 'piSettings' : toolMode]
541
+ if (!existsSync(configPath)) {
542
+ return { success: false, error: 'Config file not found' }
543
+ }
544
+
545
+ try {
546
+ let originalContent = readFileSync(configPath, 'utf8')
547
+ let newContent = originalContent
548
+ let modified = false
549
+
550
+ switch (toolMode) {
551
+ case 'goose':
552
+ if (originalContent.includes(`GOOSE_MODEL: ${modelId}`)) {
553
+ newContent = originalContent.replace(/^GOOSE_MODEL:.*$/m, '# GOOSE_MODEL: (disabled by FCM)\n# GOOSE_MODEL: ' + modelId)
554
+ modified = true
555
+ }
556
+ break
557
+
558
+ case 'crush':
559
+ const crushConfig = JSON.parse(originalContent)
560
+ if (crushConfig.models?.large?.model === modelId || crushConfig.models?.small?.model === modelId) {
561
+ if (crushConfig.models?.large?.model === modelId) {
562
+ delete crushConfig.models.large
563
+ }
564
+ if (crushConfig.models?.small?.model === modelId) {
565
+ delete crushConfig.models.small
566
+ }
567
+ newContent = JSON.stringify(crushConfig, null, 2)
568
+ modified = true
569
+ }
570
+ break
571
+
572
+ case 'aider':
573
+ if (originalContent.includes(`model: openai/${modelId}`)) {
574
+ newContent = originalContent.replace(/^model:.*$/m, '# model: (disabled by FCM)\n# model: openai/' + modelId)
575
+ modified = true
576
+ }
577
+ break
578
+
579
+ case 'qwen':
580
+ const qwenConfig = JSON.parse(originalContent)
581
+ if (qwenConfig.model === modelId) {
582
+ delete qwenConfig.model
583
+ newContent = JSON.stringify(qwenConfig, null, 2)
584
+ modified = true
585
+ }
586
+ break
587
+
588
+ case 'pi':
589
+ const piConfig = JSON.parse(originalContent)
590
+ if (piConfig.defaultModel === modelId) {
591
+ delete piConfig.defaultModel
592
+ newContent = JSON.stringify(piConfig, null, 2)
593
+ modified = true
594
+ }
595
+ break
596
+
597
+ case 'openhands':
598
+ if (originalContent.includes(`export LLM_MODEL="${modelId}"`) || originalContent.includes(`export LLM_MODEL='${modelId}'`)) {
599
+ newContent = originalContent.replace(/^export LLM_MODEL=.*$/m, '# export LLM_MODEL: (disabled by FCM)\n# export LLM_MODEL="' + modelId + '"')
600
+ modified = true
601
+ }
602
+ break
603
+
604
+ case 'amp':
605
+ const ampConfig = JSON.parse(originalContent)
606
+ if (ampConfig['amp.model'] === modelId) {
607
+ delete ampConfig['amp.model']
608
+ newContent = JSON.stringify(ampConfig, null, 2)
609
+ modified = true
610
+ }
611
+ break
612
+ }
613
+
614
+ if (!modified) {
615
+ return { success: false, error: 'Model not found in config' }
616
+ }
617
+
618
+ writeFileSync(configPath, newContent)
619
+
620
+ const backups = loadBackups()
621
+ backups.disabledModels.push({
622
+ id: `${toolMode}-${modelId}-${new Date().toISOString()}`,
623
+ toolMode,
624
+ modelId,
625
+ originalConfig: originalContent,
626
+ configPath,
627
+ disabledAt: new Date().toISOString(),
628
+ reason: 'user_deleted',
629
+ })
630
+ saveBackups(backups)
631
+
632
+ return { success: true }
633
+ } catch (err) {
634
+ return { success: false, error: err.message }
635
+ }
636
+ }
@@ -35,6 +35,8 @@ import { getLastLayout, COLUMN_SORT_MAP } from './render-table.js'
35
35
  import { cycleThemeSetting, detectActiveTheme } from './theme.js'
36
36
  import { buildCommandPaletteTree, flattenCommandTree, filterCommandPaletteEntries } from './command-palette.js'
37
37
  import { WIDTH_WARNING_MIN_COLS } from './constants.js'
38
+ import { scanAllToolConfigs, softDeleteModel } from './installed-models-manager.js'
39
+ import { startExternalTool } from './tool-launchers.js'
38
40
 
39
41
  // 📖 Some providers need an explicit probe model because the first catalog entry
40
42
  // 📖 is not guaranteed to be accepted by their chat endpoint.
@@ -687,6 +689,21 @@ export function createKeyHandler(ctx) {
687
689
  state.changelogSelectedVersion = null
688
690
  }
689
691
 
692
+ function openInstalledModelsOverlay() {
693
+ state.installedModelsOpen = true
694
+ state.installedModelsCursor = 0
695
+ state.installedModelsScrollOffset = 0
696
+ state.installedModelsErrorMsg = 'Scanning...'
697
+
698
+ try {
699
+ const results = scanAllToolConfigs()
700
+ state.installedModelsData = results
701
+ state.installedModelsErrorMsg = null
702
+ } catch (err) {
703
+ state.installedModelsErrorMsg = err.message || 'Failed to scan tool configs'
704
+ }
705
+ }
706
+
690
707
  function cycleToolMode() {
691
708
  const modeOrder = getToolModeOrder()
692
709
  const currentIndex = modeOrder.indexOf(state.mode)
@@ -774,6 +791,7 @@ export function createKeyHandler(ctx) {
774
791
  return state.settingsOpen
775
792
  || state.installEndpointsOpen
776
793
  || state.toolInstallPromptOpen
794
+ || state.installedModelsOpen
777
795
  || state.recommendOpen
778
796
  || state.feedbackOpen
779
797
  || state.helpVisible
@@ -943,6 +961,7 @@ export function createKeyHandler(ctx) {
943
961
  case 'open-feedback': return openFeedbackOverlay()
944
962
  case 'open-recommend': return openRecommendOverlay()
945
963
  case 'open-install-endpoints': return openInstallEndpointsOverlay()
964
+ case 'open-installed-models': return openInstalledModelsOverlay()
946
965
  case 'action-cycle-theme': return cycleGlobalTheme()
947
966
  case 'action-cycle-tool-mode': return cycleToolMode()
948
967
  case 'action-cycle-ping-mode': {
@@ -1289,6 +1308,104 @@ export function createKeyHandler(ctx) {
1289
1308
  return
1290
1309
  }
1291
1310
 
1311
+ // ─── Installed Models overlay keyboard handling ───────────────────────────
1312
+ if (state.installedModelsOpen) {
1313
+ if (key.ctrl && key.name === 'c') { exit(0); return }
1314
+
1315
+ const scanResults = state.installedModelsData || []
1316
+ let maxIndex = 0
1317
+ for (const toolResult of scanResults) {
1318
+ maxIndex += 1
1319
+ maxIndex += toolResult.models.length
1320
+ }
1321
+ if (maxIndex > 0) maxIndex--
1322
+
1323
+ const pageStep = Math.max(1, (state.terminalRows || 1) - 4)
1324
+
1325
+ if (key.name === 'up' || (key.shift && key.name === 'tab')) {
1326
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - 1)
1327
+ return
1328
+ }
1329
+ if (key.name === 'down' || key.name === 'tab') {
1330
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + 1)
1331
+ return
1332
+ }
1333
+ if (key.name === 'pageup') {
1334
+ state.installedModelsCursor = Math.max(0, state.installedModelsCursor - pageStep)
1335
+ return
1336
+ }
1337
+ if (key.name === 'pagedown') {
1338
+ state.installedModelsCursor = Math.min(maxIndex, state.installedModelsCursor + pageStep)
1339
+ return
1340
+ }
1341
+ if (key.name === 'home') {
1342
+ state.installedModelsCursor = 0
1343
+ return
1344
+ }
1345
+ if (key.name === 'end') {
1346
+ state.installedModelsCursor = maxIndex
1347
+ return
1348
+ }
1349
+
1350
+ if (key.name === 'escape') {
1351
+ state.installedModelsOpen = false
1352
+ state.installedModelsCursor = 0
1353
+ return
1354
+ }
1355
+
1356
+ if (key.name === 'return') {
1357
+ let currentIdx = 0
1358
+ for (const toolResult of scanResults) {
1359
+ if (currentIdx === state.installedModelsCursor) {
1360
+ return
1361
+ }
1362
+ currentIdx++
1363
+ for (const model of toolResult.models) {
1364
+ if (currentIdx === state.installedModelsCursor) {
1365
+ const selectedModel = {
1366
+ modelId: model.modelId,
1367
+ providerKey: model.providerKey,
1368
+ label: model.label,
1369
+ }
1370
+
1371
+ state.installedModelsOpen = false
1372
+ await startExternalTool(toolResult.toolMode, selectedModel, state.config)
1373
+ return
1374
+ }
1375
+ currentIdx++
1376
+ }
1377
+ }
1378
+ }
1379
+
1380
+ if (key.name === 'd') {
1381
+ let currentIdx = 0
1382
+ for (const toolResult of scanResults) {
1383
+ currentIdx++
1384
+ for (const model of toolResult.models) {
1385
+ if (currentIdx === state.installedModelsCursor) {
1386
+ softDeleteModel(toolResult.toolMode, model.modelId)
1387
+ .then((result) => {
1388
+ if (result.success) {
1389
+ openInstalledModelsOverlay()
1390
+ } else {
1391
+ state.installedModelsErrorMsg = `Failed to disable: ${result.error}`
1392
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1393
+ }
1394
+ })
1395
+ .catch((err) => {
1396
+ state.installedModelsErrorMsg = `Failed to disable: ${err.message}`
1397
+ setTimeout(() => { state.installedModelsErrorMsg = null }, 3000)
1398
+ })
1399
+ return
1400
+ }
1401
+ currentIdx++
1402
+ }
1403
+ }
1404
+ }
1405
+
1406
+ return
1407
+ }
1408
+
1292
1409
  // 📖 Incompatible fallback overlay: ↑↓ navigate across tool + model sections, Enter confirms, Esc cancels.
1293
1410
  // 📖 Cursor is a flat index: 0..N-1 = compatible tools, N..N+M-1 = similar models.
1294
1411
  if (state.incompatibleFallbackOpen) {
@@ -2327,6 +2444,22 @@ export function createMouseEventHandler(ctx) {
2327
2444
  }
2328
2445
  return
2329
2446
  }
2447
+ if (state.installedModelsOpen) {
2448
+ const scanResults = state.installedModelsData || []
2449
+ let maxIndex = 0
2450
+ for (const toolResult of scanResults) {
2451
+ maxIndex += 1
2452
+ maxIndex += toolResult.models.length
2453
+ }
2454
+ if (maxIndex > 0) maxIndex--
2455
+
2456
+ if (evt.type === 'scroll-up') {
2457
+ state.installedModelsCursor = Math.max(0, (state.installedModelsCursor || 0) - 1)
2458
+ } else {
2459
+ state.installedModelsCursor = Math.min(maxIndex, (state.installedModelsCursor || 0) + 1)
2460
+ }
2461
+ return
2462
+ }
2330
2463
 
2331
2464
  // 📖 Main table scroll: move cursor up/down with wrap-around
2332
2465
  const count = state.visibleSorted.length
@@ -2402,6 +2535,11 @@ export function createMouseEventHandler(ctx) {
2402
2535
  return
2403
2536
  }
2404
2537
 
2538
+ if (state.installedModelsOpen) {
2539
+ state.installedModelsOpen = false
2540
+ return
2541
+ }
2542
+
2405
2543
  if (state.incompatibleFallbackOpen) {
2406
2544
  // 📖 Incompatible fallback: click closes
2407
2545
  state.incompatibleFallbackOpen = false
package/src/overlays.js CHANGED
@@ -479,6 +479,114 @@ export function createOverlayRenderers(state, deps) {
479
479
  return cleared.join('\n')
480
480
  }
481
481
 
482
+ // ─── Installed Models Manager overlay renderer ─────────────────────────────
483
+ // 📖 renderInstalledModels displays all models configured in external tools
484
+ // 📖 Shows tool configs, model lists, and provides actions (Launch, Disable, Reinstall)
485
+ function renderInstalledModels() {
486
+ const EL = '\x1b[K'
487
+ const lines = []
488
+ const cursorLineByRow = {}
489
+
490
+ lines.push('')
491
+ lines.push(` ${themeColors.accent('🚀')} ${themeColors.accentBold('free-coding-models')} ${themeColors.dim(`v${LOCAL_VERSION}`)}`)
492
+ lines.push(` ${themeColors.textBold('🗂️ Installed Models Manager')}`)
493
+ lines.push('')
494
+ lines.push(themeColors.dim(' — models configured in your tools'))
495
+
496
+ if (state.installedModelsErrorMsg) {
497
+ lines.push(` ${themeColors.warning(state.installedModelsErrorMsg)}`)
498
+ }
499
+
500
+ if (state.installedModelsErrorMsg === 'Scanning...') {
501
+ lines.push(themeColors.dim(' Scanning tool configs, please wait...'))
502
+ const targetLine = 5
503
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
504
+ state.installedModelsScrollOffset,
505
+ targetLine,
506
+ lines.length,
507
+ state.terminalRows
508
+ )
509
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
510
+ state.installedModelsScrollOffset = offset
511
+
512
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
513
+ overlayLayout.installedModelsScrollOffset = offset
514
+
515
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
516
+ const cleared = tintedLines.map((l) => l + EL)
517
+ return cleared.join('\n')
518
+ }
519
+
520
+ lines.push('')
521
+
522
+ const scanResults = state.installedModelsData || []
523
+
524
+ if (scanResults.length === 0) {
525
+ lines.push(themeColors.dim(' No tool configs found.'))
526
+ lines.push(themeColors.dim(' Install a tool (Goose, Crush, Aider, etc.) to get started.'))
527
+ } else {
528
+ let globalIdx = 0
529
+
530
+ for (const toolResult of scanResults) {
531
+ const { toolMode, toolLabel, toolEmoji, configPath, isValid, hasManagedMarker, models } = toolResult
532
+
533
+ lines.push('')
534
+ const isCursor = globalIdx === state.installedModelsCursor
535
+
536
+ const statusIcon = isValid ? themeColors.successBold('✅') : themeColors.errorBold('⚠️')
537
+ const toolHeader = `${bullet(isCursor)}${toolEmoji} ${themeColors.textBold(toolLabel)} ${statusIcon}`
538
+ cursorLineByRow[globalIdx++] = lines.length
539
+ lines.push(isCursor ? themeColors.bgCursor(toolHeader) : toolHeader)
540
+
541
+ const configShortPath = configPath.replace(process.env.HOME || homedir(), '~')
542
+ lines.push(` ${themeColors.dim(configShortPath)}`)
543
+
544
+ if (!isValid) {
545
+ lines.push(themeColors.dim(' ⚠️ Config invalid or missing'))
546
+ } else if (models.length === 0) {
547
+ lines.push(themeColors.dim(' No models configured'))
548
+ } else {
549
+ const managedBadge = hasManagedMarker ? themeColors.info('• Managed by FCM') : themeColors.dim('• External config')
550
+ lines.push(` ${themeColors.success(`${models.length} model${models.length > 1 ? 's' : ''} configured`)} ${managedBadge}`)
551
+
552
+ for (const model of models) {
553
+ const isModelCursor = globalIdx === state.installedModelsCursor
554
+ const tierBadge = model.tier !== '-' ? themeColors.info(model.tier.padEnd(2)) : themeColors.dim(' ')
555
+ const externalBadge = model.isExternal ? themeColors.dim('[external]') : ''
556
+
557
+ const modelRow = ` • ${model.label} ${tierBadge} ${externalBadge}`
558
+ cursorLineByRow[globalIdx++] = lines.length
559
+ lines.push(isModelCursor ? themeColors.bgCursor(modelRow) : modelRow)
560
+
561
+ if (isModelCursor) {
562
+ lines.push(` ${themeColors.dim('[Enter] Launch [D] Disable')}`)
563
+ }
564
+ }
565
+ }
566
+ }
567
+ }
568
+
569
+ lines.push('')
570
+ lines.push(themeColors.dim(' ↑↓ Navigate Enter=Launch D=Disable Esc=Close'))
571
+
572
+ const targetLine = cursorLineByRow[state.installedModelsCursor] ?? 0
573
+ state.installedModelsScrollOffset = keepOverlayTargetVisible(
574
+ state.installedModelsScrollOffset,
575
+ targetLine,
576
+ lines.length,
577
+ state.terminalRows
578
+ )
579
+ const { visible, offset } = sliceOverlayLines(lines, state.installedModelsScrollOffset, state.terminalRows)
580
+ state.installedModelsScrollOffset = offset
581
+
582
+ overlayLayout.installedModelsCursorToLine = cursorLineByRow
583
+ overlayLayout.installedModelsScrollOffset = offset
584
+
585
+ const tintedLines = tintOverlayLines(visible, themeColors.overlayBgSettings, state.terminalCols)
586
+ const cleared = tintedLines.map((l) => l + EL)
587
+ return cleared.join('\n')
588
+ }
589
+
482
590
  // ─── Missing-tool install confirmation overlay ────────────────────────────
483
591
  // 📖 renderToolInstallPrompt keeps the user inside the TUI long enough to
484
592
  // 📖 confirm the auto-install, then the key handler exits the alt screen and
@@ -1381,6 +1489,7 @@ export function createOverlayRenderers(state, deps) {
1381
1489
  renderRecommend,
1382
1490
  renderFeedback,
1383
1491
  renderChangelog,
1492
+ renderInstalledModels,
1384
1493
  renderIncompatibleFallback,
1385
1494
  startRecommendAnalysis,
1386
1495
  stopRecommendAnalysis,