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 +47 -2
- package/README.md +18 -0
- package/package.json +1 -1
- package/sources.js +37 -19
- package/src/app.js +9 -1
- package/src/command-palette.js +1 -0
- package/src/installed-models-manager.js +636 -0
- package/src/key-handler.js +138 -0
- package/src/overlays.js +109 -0
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
|
-
- **
|
|
8
|
-
- **
|
|
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.
|
|
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%', '
|
|
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
|
-
['
|
|
162
|
-
['
|
|
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
|
-
['
|
|
165
|
-
['
|
|
166
|
-
['
|
|
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%', '
|
|
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-
|
|
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-
|
|
178
|
-
['
|
|
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
|
-
['
|
|
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
|
-
['
|
|
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',
|
|
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',
|
|
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
|
package/src/command-palette.js
CHANGED
|
@@ -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
|
+
}
|
package/src/key-handler.js
CHANGED
|
@@ -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,
|