| 1 | package tui |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "strings" |
| 7 | |
| 8 | "os" |
| 9 | |
| 10 | "sourcecraft.dev/bigbes/claudio/cache" |
| 11 | "sourcecraft.dev/bigbes/claudio/config" |
| 12 | "sourcecraft.dev/bigbes/claudio/copilot" |
| 13 | "sourcecraft.dev/bigbes/claudio/nvidia" |
| 14 | ollamaAPI "sourcecraft.dev/bigbes/claudio/ollama" |
| 15 | "sourcecraft.dev/bigbes/claudio/openrouter" |
| 16 | "sourcecraft.dev/bigbes/claudio/provider" |
| 17 | "sourcecraft.dev/bigbes/claudio/vkproxy" |
| 18 | "github.com/charmbracelet/bubbles/filepicker" |
| 19 | "github.com/charmbracelet/bubbles/textinput" |
| 20 | tea "github.com/charmbracelet/bubbletea" |
| 21 | "github.com/charmbracelet/lipgloss" |
| 22 | ) |
| 23 | |
| 24 | const logo = ` |
| 25 | _ _ _ |
| 26 | ___| | __ _ _ _ __| (_) ___ |
| 27 | / __| |/ _` + "`" + ` | | | |/ _` + "`" + ` | |/ _ \ |
| 28 | | (__| | (_| | |_| | (_| | | (_) | |
| 29 | \___|_|\__,_|\__,_|\__,_|_|\___/ ` |
| 30 | |
| 31 | type phase int |
| 32 | |
| 33 | const ( |
| 34 | phaseSelect phase = iota |
| 35 | phaseInputKey |
| 36 | phaseSelectPlan |
| 37 | phaseLoadingModels |
| 38 | phaseSelectModel |
| 39 | phaseFilePicker |
| 40 | phaseDone |
| 41 | ) |
| 42 | |
| 43 | type entry struct { |
| 44 | Key string |
| 45 | Name string |
| 46 | Description string |
| 47 | } |
| 48 | |
| 49 | // PickerItem is a generic model entry for the two-column picker. |
| 50 | type PickerItem struct { |
| 51 | ID string |
| 52 | Name string |
| 53 | ContextLength int |
| 54 | } |
| 55 | |
| 56 | // DisplayName returns a human-friendly label for the item. |
| 57 | func (p PickerItem) DisplayName() string { |
| 58 | if p.Name != "" { |
| 59 | return p.Name |
| 60 | } |
| 61 | return p.ID |
| 62 | } |
| 63 | |
| 64 | // pickerModelsMsg is returned by the async model-fetch command. |
| 65 | type pickerModelsMsg struct { |
| 66 | families []string |
| 67 | models map[string][]PickerItem |
| 68 | err error |
| 69 | |
| 70 | // Popular provider drill-down data (openrouter only). |
| 71 | popularProviders []string |
| 72 | popularProviderModels map[string][]PickerItem |
| 73 | |
| 74 | // All model IDs for "enter by hand" validation (openrouter only). |
| 75 | allModelIDs map[string]bool |
| 76 | } |
| 77 | |
| 78 | type Model struct { |
| 79 | cfg *config.Config |
| 80 | cache *cache.DB |
| 81 | entries []entry |
| 82 | phase phase |
| 83 | cursor int |
| 84 | selected string |
| 85 | input textinput.Model |
| 86 | quitting bool |
| 87 | width int |
| 88 | height int |
| 89 | |
| 90 | // Model picker state (copilot or openrouter). |
| 91 | pickerProvider string |
| 92 | modelCursor int |
| 93 | modelsErr error |
| 94 | |
| 95 | // Two-column model picker state. |
| 96 | families []string |
| 97 | familyModels map[string][]PickerItem |
| 98 | familyCursor int |
| 99 | modelFocus bool // true = right column focused |
| 100 | |
| 101 | // Popular provider drill-down state (openrouter). |
| 102 | popularProviders []string |
| 103 | popularProviderModels map[string][]PickerItem |
| 104 | popularProviderCursor int |
| 105 | popularDrilldown bool // true = browsing models within a provider |
| 106 | |
| 107 | // "Enter model ID" input state (openrouter). |
| 108 | pickerInput textinput.Model |
| 109 | pickerInputActive bool |
| 110 | pickerInputErr string |
| 111 | allModelIDs map[string]bool |
| 112 | |
| 113 | // Provider filter state (phaseSelect). |
| 114 | filterActive bool |
| 115 | filterInput textinput.Model |
| 116 | filteredIdx []int // indices into entries matching filter |
| 117 | |
| 118 | // Show hidden providers toggle (phaseSelect). |
| 119 | showHidden bool |
| 120 | |
| 121 | // Transient status message (e.g. after refresh). |
| 122 | statusMsg string |
| 123 | |
| 124 | // File picker state (vkproxy zip selection). |
| 125 | filePicker filepicker.Model |
| 126 | |
| 127 | // Plan selection state (phaseSelectPlan). |
| 128 | plans []provider.Plan |
| 129 | planCursor int |
| 130 | |
| 131 | // Model picker filter state (phaseSelectModel). |
| 132 | pickerFilterActive bool |
| 133 | pickerFilteredIdx []int |
| 134 | pickerFilterCursor int |
| 135 | pickerFilterRight bool // true = filtering right column, false = left |
| 136 | } |
| 137 | |
| 138 | func buildEntries(cfg *config.Config, showHidden bool) []entry { |
| 139 | seen := make(map[string]bool) |
| 140 | var entries []entry |
| 141 | for _, key := range provider.Order { |
| 142 | if !showHidden && cfg.Providers[key].Hidden { |
| 143 | seen[key] = true |
| 144 | continue |
| 145 | } |
| 146 | p := provider.Registry[key] |
| 147 | entries = append(entries, entry{Key: key, Name: p.Name, Description: p.Description}) |
| 148 | seen[key] = true |
| 149 | } |
| 150 | for key, pc := range cfg.Providers { |
| 151 | if seen[key] || (!showHidden && pc.Hidden) { |
| 152 | continue |
| 153 | } |
| 154 | name := pc.Name |
| 155 | if name == "" { |
| 156 | name = key |
| 157 | } |
| 158 | desc := pc.Description |
| 159 | if desc == "" && pc.Compat == "openai" { |
| 160 | desc = "Custom OpenAI-compatible provider" |
| 161 | } |
| 162 | entries = append(entries, entry{Key: key, Name: name, Description: desc}) |
| 163 | } |
| 164 | return entries |
| 165 | } |
| 166 | |
| 167 | func New(cfg *config.Config, cdb *cache.DB) Model { |
| 168 | ti := textinput.New() |
| 169 | ti.Placeholder = "sk-..." |
| 170 | ti.CharLimit = 256 |
| 171 | ti.Width = 60 |
| 172 | ti.EchoMode = textinput.EchoPassword |
| 173 | ti.EchoCharacter = '•' |
| 174 | |
| 175 | entries := buildEntries(cfg, false) |
| 176 | |
| 177 | cursor := 0 |
| 178 | for i, e := range entries { |
| 179 | if e.Key == cfg.ActiveProvider { |
| 180 | cursor = i |
| 181 | break |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | fi := textinput.New() |
| 186 | fi.Placeholder = "filter..." |
| 187 | fi.CharLimit = 64 |
| 188 | fi.Width = 30 |
| 189 | |
| 190 | return Model{ |
| 191 | cfg: cfg, |
| 192 | cache: cdb, |
| 193 | entries: entries, |
| 194 | phase: phaseSelect, |
| 195 | cursor: cursor, |
| 196 | input: ti, |
| 197 | filterInput: fi, |
| 198 | } |
| 199 | } |
| 200 | |
| 201 | // NewModelPicker creates a Model that goes directly to the copilot model selection phase. |
| 202 | func NewModelPicker(cfg *config.Config, cdb *cache.DB, models []copilot.CopilotModel) Model { |
| 203 | families, grouped := copilot.VendorFamilies(models) |
| 204 | items := make(map[string][]PickerItem) |
| 205 | for fam, cms := range grouped { |
| 206 | for _, cm := range cms { |
| 207 | items[fam] = append(items[fam], PickerItem{ID: cm.ID, Name: cm.DisplayName(), ContextLength: cm.ContextWindow}) |
| 208 | } |
| 209 | } |
| 210 | m := Model{ |
| 211 | cfg: cfg, |
| 212 | cache: cdb, |
| 213 | phase: phaseSelectModel, |
| 214 | pickerProvider: "copilot", |
| 215 | families: families, |
| 216 | familyModels: items, |
| 217 | } |
| 218 | m.preSelectModel() |
| 219 | return m |
| 220 | } |
| 221 | |
| 222 | // preSelectModel positions cursors on the currently active model. |
| 223 | func (m *Model) preSelectModel() { |
| 224 | m.familyCursor = 0 |
| 225 | m.modelCursor = 0 |
| 226 | m.modelFocus = false |
| 227 | |
| 228 | pc := m.cfg.Providers[m.pickerProvider] |
| 229 | current := pc.Model |
| 230 | if current == "" { |
| 231 | if reg, ok := provider.Registry[m.pickerProvider]; ok { |
| 232 | current = reg.Model |
| 233 | } |
| 234 | } |
| 235 | for fi, fam := range m.families { |
| 236 | for mi, item := range m.familyModels[fam] { |
| 237 | if item.ID == current { |
| 238 | m.familyCursor = fi |
| 239 | m.modelCursor = mi |
| 240 | return |
| 241 | } |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func (m Model) Cfg() *config.Config { return m.cfg } |
| 247 | func (m Model) Quitting() bool { return m.quitting } |
| 248 | |
| 249 | func (m Model) Init() tea.Cmd { return nil } |
| 250 | |
| 251 | func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 252 | if msg, ok := msg.(tea.WindowSizeMsg); ok { |
| 253 | m.width = msg.Width |
| 254 | m.height = msg.Height |
| 255 | } |
| 256 | switch m.phase { |
| 257 | case phaseSelect: |
| 258 | return m.updateSelect(msg) |
| 259 | case phaseInputKey: |
| 260 | return m.updateInput(msg) |
| 261 | case phaseSelectPlan: |
| 262 | return m.updateSelectPlan(msg) |
| 263 | case phaseLoadingModels: |
| 264 | return m.updateLoadingModels(msg) |
| 265 | case phaseSelectModel: |
| 266 | return m.updateSelectModel(msg) |
| 267 | case phaseFilePicker: |
| 268 | return m.updateFilePicker(msg) |
| 269 | default: |
| 270 | return m, tea.Quit |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | func (m *Model) recomputeFilter() { |
| 275 | query := strings.ToLower(m.filterInput.Value()) |
| 276 | m.filteredIdx = nil |
| 277 | for i, e := range m.entries { |
| 278 | if query == "" || strings.Contains(strings.ToLower(e.Name), query) || strings.Contains(strings.ToLower(e.Key), query) { |
| 279 | m.filteredIdx = append(m.filteredIdx, i) |
| 280 | } |
| 281 | } |
| 282 | if m.cursor >= len(m.filteredIdx) { |
| 283 | m.cursor = max(0, len(m.filteredIdx)-1) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | func (m Model) resolveEntry() entry { |
| 288 | if m.filterActive && len(m.filteredIdx) > 0 && m.cursor < len(m.filteredIdx) { |
| 289 | return m.entries[m.filteredIdx[m.cursor]] |
| 290 | } |
| 291 | return m.entries[m.cursor] |
| 292 | } |
| 293 | |
| 294 | func (m Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 295 | if m.filterActive { |
| 296 | return m.updateSelectFilter(msg) |
| 297 | } |
| 298 | switch msg := msg.(type) { |
| 299 | case tea.KeyMsg: |
| 300 | m.modelsErr = nil |
| 301 | switch msg.String() { |
| 302 | case "ctrl+c", "q": |
| 303 | m.quitting = true |
| 304 | return m, tea.Quit |
| 305 | case "up", "k": |
| 306 | if m.cursor > 0 { |
| 307 | m.cursor-- |
| 308 | } |
| 309 | case "down", "j": |
| 310 | if m.cursor < len(m.entries)-1 { |
| 311 | m.cursor++ |
| 312 | } |
| 313 | case "/": |
| 314 | m.filterActive = true |
| 315 | m.filterInput.SetValue("") |
| 316 | m.filterInput.Focus() |
| 317 | m.cursor = 0 |
| 318 | m.recomputeFilter() |
| 319 | return m, m.filterInput.Cursor.BlinkCmd() |
| 320 | case "enter": |
| 321 | m.selected = m.entries[m.cursor].Key |
| 322 | return m.selectProvider() |
| 323 | case "x": |
| 324 | // Toggle hidden state for current provider. |
| 325 | key := m.entries[m.cursor].Key |
| 326 | pc := m.cfg.Providers[key] |
| 327 | pc.Hidden = !pc.Hidden |
| 328 | m.cfg.Providers[key] = pc |
| 329 | if !m.showHidden && pc.Hidden { |
| 330 | m.entries = buildEntries(m.cfg, m.showHidden) |
| 331 | if m.cursor >= len(m.entries) { |
| 332 | m.cursor = max(0, len(m.entries)-1) |
| 333 | } |
| 334 | } |
| 335 | return m, nil |
| 336 | case "H": |
| 337 | // Toggle showing hidden providers. |
| 338 | m.showHidden = !m.showHidden |
| 339 | prev := "" |
| 340 | if m.cursor < len(m.entries) { |
| 341 | prev = m.entries[m.cursor].Key |
| 342 | } |
| 343 | m.entries = buildEntries(m.cfg, m.showHidden) |
| 344 | m.cursor = 0 |
| 345 | for i, e := range m.entries { |
| 346 | if e.Key == prev { |
| 347 | m.cursor = i |
| 348 | break |
| 349 | } |
| 350 | } |
| 351 | return m, nil |
| 352 | case "e": |
| 353 | // Edit key for current provider. |
| 354 | m.selected = m.entries[m.cursor].Key |
| 355 | existing := "" |
| 356 | if pc, ok := m.cfg.Providers[m.selected]; ok { |
| 357 | existing = pc.APIKey |
| 358 | } |
| 359 | m.input.SetValue(existing) |
| 360 | m.input.Focus() |
| 361 | m.phase = phaseInputKey |
| 362 | return m, m.input.Cursor.BlinkCmd() |
| 363 | } |
| 364 | } |
| 365 | return m, nil |
| 366 | } |
| 367 | |
| 368 | func (m Model) updateSelectFilter(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 369 | switch msg := msg.(type) { |
| 370 | case tea.KeyMsg: |
| 371 | switch msg.String() { |
| 372 | case "ctrl+c": |
| 373 | m.quitting = true |
| 374 | return m, tea.Quit |
| 375 | case "esc": |
| 376 | m.filterActive = false |
| 377 | // Restore cursor to the full list position. |
| 378 | if len(m.filteredIdx) > 0 && m.cursor < len(m.filteredIdx) { |
| 379 | m.cursor = m.filteredIdx[m.cursor] |
| 380 | } else { |
| 381 | m.cursor = 0 |
| 382 | } |
| 383 | return m, nil |
| 384 | case "up": |
| 385 | if m.cursor > 0 { |
| 386 | m.cursor-- |
| 387 | } |
| 388 | return m, nil |
| 389 | case "down": |
| 390 | if m.cursor < len(m.filteredIdx)-1 { |
| 391 | m.cursor++ |
| 392 | } |
| 393 | return m, nil |
| 394 | case "enter": |
| 395 | if len(m.filteredIdx) == 0 { |
| 396 | return m, nil |
| 397 | } |
| 398 | m.selected = m.resolveEntry().Key |
| 399 | m.filterActive = false |
| 400 | return m.selectProvider() |
| 401 | } |
| 402 | } |
| 403 | var cmd tea.Cmd |
| 404 | m.filterInput, cmd = m.filterInput.Update(msg) |
| 405 | m.recomputeFilter() |
| 406 | return m, cmd |
| 407 | } |
| 408 | |
| 409 | func (m Model) selectProvider() (tea.Model, tea.Cmd) { |
| 410 | // VK LLM Proxy: always parse zip and show picker. |
| 411 | if m.selected == "vkproxy" { |
| 412 | return m.startVKProxyPicker() |
| 413 | } |
| 414 | // If provider already has a key, just activate it. |
| 415 | if pc, ok := m.cfg.Providers[m.selected]; ok && pc.APIKey != "" { |
| 416 | m.cfg.ActiveProvider = m.selected |
| 417 | // For copilot, show model picker. |
| 418 | if m.selected == "copilot" { |
| 419 | return m.startModelFetch(pc.APIKey) |
| 420 | } |
| 421 | // For openrouter, show model picker. |
| 422 | if m.selected == "openrouter" { |
| 423 | return m.startORModelFetch(pc.APIKey) |
| 424 | } |
| 425 | // For nvidia, show model picker. |
| 426 | if m.selected == "nvidia" { |
| 427 | return m.startNvidiaModelFetch(pc.APIKey) |
| 428 | } |
| 429 | // For ollama or ollama-cloud, show model picker. |
| 430 | if m.selected == "ollama" || m.selected == "ollama-cloud" { |
| 431 | reg := provider.Registry[m.selected] |
| 432 | return m.startOllamaModelFetch(pc.APIKey, reg.BaseURL) |
| 433 | } |
| 434 | // For providers with plans, show plan picker. |
| 435 | if reg, ok := provider.Registry[m.selected]; ok && len(reg.Plans) > 0 { |
| 436 | return m.showPlanPicker(reg.Plans), nil |
| 437 | } |
| 438 | // For providers with static model lists, show model picker. |
| 439 | if models, ok := provider.StaticModels[m.selected]; ok { |
| 440 | return m.showStaticModelPicker(models), nil |
| 441 | } |
| 442 | m.phase = phaseDone |
| 443 | return m, tea.Quit |
| 444 | } |
| 445 | // Copilot uses OAuth device flow; just activate and let main handle auth. |
| 446 | if m.selected == "copilot" { |
| 447 | m.cfg.ActiveProvider = m.selected |
| 448 | m.phase = phaseDone |
| 449 | return m, tea.Quit |
| 450 | } |
| 451 | // NoAuth providers (e.g. local Ollama) don't need an API key. |
| 452 | if reg, ok := provider.Registry[m.selected]; ok && reg.NoAuth { |
| 453 | m.cfg.ActiveProvider = m.selected |
| 454 | // For ollama, show model picker. |
| 455 | if m.selected == "ollama" { |
| 456 | return m.startOllamaModelFetch("", reg.BaseURL) |
| 457 | } |
| 458 | m.phase = phaseDone |
| 459 | return m, tea.Quit |
| 460 | } |
| 461 | // Otherwise prompt for key. |
| 462 | m.input.SetValue("") |
| 463 | m.input.Focus() |
| 464 | m.phase = phaseInputKey |
| 465 | return m, m.input.Cursor.BlinkCmd() |
| 466 | } |
| 467 | |
| 468 | func (m Model) updateInput(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 469 | switch msg := msg.(type) { |
| 470 | case tea.KeyMsg: |
| 471 | switch msg.String() { |
| 472 | case "ctrl+c": |
| 473 | m.quitting = true |
| 474 | return m, tea.Quit |
| 475 | case "esc": |
| 476 | m.phase = phaseSelect |
| 477 | return m, nil |
| 478 | case "enter": |
| 479 | key := strings.TrimSpace(m.input.Value()) |
| 480 | if key == "" { |
| 481 | return m, nil |
| 482 | } |
| 483 | m.cfg.ActiveProvider = m.selected |
| 484 | pc := m.cfg.Providers[m.selected] |
| 485 | pc.APIKey = key |
| 486 | m.cfg.Providers[m.selected] = pc |
| 487 | // For openrouter, show model picker after entering key. |
| 488 | if m.selected == "openrouter" { |
| 489 | return m.startORModelFetch(key) |
| 490 | } |
| 491 | // For nvidia, show model picker after entering key. |
| 492 | if m.selected == "nvidia" { |
| 493 | return m.startNvidiaModelFetch(key) |
| 494 | } |
| 495 | // For ollama-cloud, show model picker after entering key. |
| 496 | if m.selected == "ollama-cloud" { |
| 497 | reg := provider.Registry[m.selected] |
| 498 | return m.startOllamaModelFetch(key, reg.BaseURL) |
| 499 | } |
| 500 | // For providers with plans, show plan picker after entering key. |
| 501 | if reg, ok := provider.Registry[m.selected]; ok && len(reg.Plans) > 0 { |
| 502 | return m.showPlanPicker(reg.Plans), nil |
| 503 | } |
| 504 | // For providers with static model lists, show model picker after entering key. |
| 505 | if models, ok := provider.StaticModels[m.selected]; ok { |
| 506 | return m.showStaticModelPicker(models), nil |
| 507 | } |
| 508 | m.phase = phaseDone |
| 509 | return m, tea.Quit |
| 510 | } |
| 511 | } |
| 512 | |
| 513 | var cmd tea.Cmd |
| 514 | m.input, cmd = m.input.Update(msg) |
| 515 | return m, cmd |
| 516 | } |
| 517 | |
| 518 | func (m Model) showStaticModelPicker(models []provider.StaticModel) Model { |
| 519 | var familyOrder []string |
| 520 | familyMap := make(map[string][]PickerItem) |
| 521 | seen := make(map[string]bool) |
| 522 | for _, sm := range models { |
| 523 | if !seen[sm.Family] { |
| 524 | seen[sm.Family] = true |
| 525 | familyOrder = append(familyOrder, sm.Family) |
| 526 | } |
| 527 | familyMap[sm.Family] = append(familyMap[sm.Family], PickerItem{ID: sm.ID, Name: sm.Name, ContextLength: 0}) |
| 528 | } |
| 529 | m.phase = phaseSelectModel |
| 530 | m.pickerProvider = m.selected |
| 531 | m.families = familyOrder |
| 532 | m.familyModels = familyMap |
| 533 | m.preSelectModel() |
| 534 | return m |
| 535 | } |
| 536 | |
| 537 | func (m Model) showPlanPicker(plans []provider.Plan) Model { |
| 538 | m.plans = plans |
| 539 | m.planCursor = 0 |
| 540 | // Pre-select the current plan if one is saved. |
| 541 | if pc, ok := m.cfg.Providers[m.selected]; ok && pc.Plan != "" { |
| 542 | for i, p := range plans { |
| 543 | if p.Key == pc.Plan { |
| 544 | m.planCursor = i |
| 545 | break |
| 546 | } |
| 547 | } |
| 548 | } |
| 549 | m.phase = phaseSelectPlan |
| 550 | return m |
| 551 | } |
| 552 | |
| 553 | func (m Model) updateSelectPlan(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 554 | switch msg := msg.(type) { |
| 555 | case tea.KeyMsg: |
| 556 | switch msg.String() { |
| 557 | case "ctrl+c", "q": |
| 558 | m.quitting = true |
| 559 | return m, tea.Quit |
| 560 | case "esc": |
| 561 | m.phase = phaseSelect |
| 562 | return m, nil |
| 563 | case "up", "k": |
| 564 | if m.planCursor > 0 { |
| 565 | m.planCursor-- |
| 566 | } |
| 567 | case "down", "j": |
| 568 | if m.planCursor < len(m.plans)-1 { |
| 569 | m.planCursor++ |
| 570 | } |
| 571 | case "enter": |
| 572 | selected := m.plans[m.planCursor] |
| 573 | pc := m.cfg.Providers[m.selected] |
| 574 | pc.Plan = selected.Key |
| 575 | m.cfg.Providers[m.selected] = pc |
| 576 | // Continue to model picker if available. |
| 577 | if models, ok := provider.StaticModels[m.selected]; ok { |
| 578 | return m.showStaticModelPicker(models), nil |
| 579 | } |
| 580 | m.phase = phaseDone |
| 581 | return m, tea.Quit |
| 582 | } |
| 583 | } |
| 584 | return m, nil |
| 585 | } |
| 586 | |
| 587 | func (m Model) viewSelectPlan() string { |
| 588 | reg := provider.Registry[m.selected] |
| 589 | var b strings.Builder |
| 590 | b.WriteString(logoStyle.Render(logo)) |
| 591 | b.WriteString("\n\n") |
| 592 | b.WriteString(promptStyle.Render(fmt.Sprintf(" Select plan for %s", reg.Name))) |
| 593 | b.WriteString("\n\n") |
| 594 | |
| 595 | for i, plan := range m.plans { |
| 596 | cur := " " |
| 597 | if i == m.planCursor { |
| 598 | cur = cursorStyle.Render("▸ ") |
| 599 | } |
| 600 | name := plan.Name |
| 601 | if i == m.planCursor { |
| 602 | name = cursorStyle.Render(name) |
| 603 | } |
| 604 | desc := descStyle.Render(" " + plan.Description) |
| 605 | b.WriteString(fmt.Sprintf(" %s%s\n%s\n", cur, name, desc)) |
| 606 | } |
| 607 | |
| 608 | b.WriteString("\n") |
| 609 | b.WriteString(hintStyle.Render(" ↑/↓ navigate • enter select • esc back")) |
| 610 | |
| 611 | content := b.String() |
| 612 | if m.width > 0 && m.height > 0 { |
| 613 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 614 | } |
| 615 | return content |
| 616 | } |
| 617 | |
| 618 | func (m Model) loadVKProxyModels(forceRefresh bool) ([]vkproxy.ProxyModel, error) { |
| 619 | // Try cache first (unless forcing refresh). |
| 620 | if !forceRefresh && m.cache != nil { |
| 621 | if data, ok := m.cache.GetModels("vkproxy"); ok { |
| 622 | var models []vkproxy.ProxyModel |
| 623 | if json.Unmarshal(data, &models) == nil && len(models) > 0 { |
| 624 | return models, nil |
| 625 | } |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | // Parse from zip. |
| 630 | zipPath := vkproxy.FindZip() |
| 631 | if zipPath == "" { |
| 632 | return nil, errZipNotFound |
| 633 | } |
| 634 | return m.loadVKProxyModelsFromPath(zipPath) |
| 635 | } |
| 636 | |
| 637 | var errZipNotFound = fmt.Errorf("claude-code-config.zip not found") |
| 638 | |
| 639 | func (m Model) loadVKProxyModelsFromPath(zipPath string) ([]vkproxy.ProxyModel, error) { |
| 640 | models, err := vkproxy.ParseZip(zipPath) |
| 641 | if err != nil { |
| 642 | return nil, err |
| 643 | } |
| 644 | if len(models) == 0 { |
| 645 | return nil, fmt.Errorf("no models found in zip") |
| 646 | } |
| 647 | |
| 648 | // Update cache. |
| 649 | if m.cache != nil { |
| 650 | if data, err := json.Marshal(models); err == nil { |
| 651 | m.cache.SetModels("vkproxy", data) |
| 652 | } |
| 653 | } |
| 654 | return models, nil |
| 655 | } |
| 656 | |
| 657 | func (m Model) startVKProxyPicker() (tea.Model, tea.Cmd) { |
| 658 | return m.startVKProxyPickerWithRefresh(false) |
| 659 | } |
| 660 | |
| 661 | func (m Model) startVKProxyPickerWithRefresh(forceRefresh bool) (tea.Model, tea.Cmd) { |
| 662 | models, err := m.loadVKProxyModels(forceRefresh) |
| 663 | if err == errZipNotFound { |
| 664 | return m.showZipFilePicker() |
| 665 | } |
| 666 | if err != nil { |
| 667 | m.modelsErr = err |
| 668 | m.phase = phaseSelect |
| 669 | return m, nil |
| 670 | } |
| 671 | return m.showVKProxyModels(models) |
| 672 | } |
| 673 | |
| 674 | func (m Model) showZipFilePicker() (tea.Model, tea.Cmd) { |
| 675 | fp := filepicker.New() |
| 676 | fp.AllowedTypes = []string{".zip"} |
| 677 | fp.ShowHidden = false |
| 678 | fp.ShowSize = true |
| 679 | fp.ShowPermissions = true |
| 680 | fp.DirAllowed = false |
| 681 | fp.FileAllowed = true |
| 682 | fp.AutoHeight = false |
| 683 | fp.Height = 20 |
| 684 | fp.Cursor = "▸" |
| 685 | fp.Styles.Cursor = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) |
| 686 | fp.Styles.Selected = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) |
| 687 | fp.Styles.Directory = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Bold(true) |
| 688 | fp.Styles.Symlink = lipgloss.NewStyle().Foreground(lipgloss.Color("36")) |
| 689 | fp.Styles.File = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) |
| 690 | fp.Styles.DisabledFile = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) |
| 691 | fp.Styles.DisabledCursor = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) |
| 692 | fp.Styles.DisabledSelected = lipgloss.NewStyle().Foreground(lipgloss.Color("240")) |
| 693 | fp.Styles.Permission = lipgloss.NewStyle().Foreground(lipgloss.Color("244")) |
| 694 | fp.Styles.FileSize = lipgloss.NewStyle().Foreground(lipgloss.Color("244")).Width(7).Align(lipgloss.Right) |
| 695 | home, _ := os.UserHomeDir() |
| 696 | if home != "" { |
| 697 | fp.CurrentDirectory = home |
| 698 | } else { |
| 699 | fp.CurrentDirectory, _ = os.Getwd() |
| 700 | } |
| 701 | m.filePicker = fp |
| 702 | m.phase = phaseFilePicker |
| 703 | return m, m.filePicker.Init() |
| 704 | } |
| 705 | |
| 706 | func (m Model) updateFilePicker(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 707 | switch msg := msg.(type) { |
| 708 | case tea.KeyMsg: |
| 709 | if msg.String() == "ctrl+c" { |
| 710 | m.quitting = true |
| 711 | return m, tea.Quit |
| 712 | } |
| 713 | if msg.String() == "q" { |
| 714 | m.phase = phaseSelect |
| 715 | return m, nil |
| 716 | } |
| 717 | } |
| 718 | |
| 719 | var cmd tea.Cmd |
| 720 | m.filePicker, cmd = m.filePicker.Update(msg) |
| 721 | |
| 722 | if didSelect, path := m.filePicker.DidSelectFile(msg); didSelect { |
| 723 | models, err := m.loadVKProxyModelsFromPath(path) |
| 724 | if err != nil { |
| 725 | m.modelsErr = err |
| 726 | m.phase = phaseSelect |
| 727 | return m, nil |
| 728 | } |
| 729 | return m.showVKProxyModels(models) |
| 730 | } |
| 731 | |
| 732 | return m, cmd |
| 733 | } |
| 734 | |
| 735 | func (m Model) viewFilePicker() string { |
| 736 | var b strings.Builder |
| 737 | b.WriteString(logoStyle.Render(logo)) |
| 738 | b.WriteString("\n\n") |
| 739 | b.WriteString(promptStyle.Render(" Select claude-code-config.zip")) |
| 740 | b.WriteString("\n\n") |
| 741 | |
| 742 | // Shorten path: replace home dir with ~. |
| 743 | dir := m.filePicker.CurrentDirectory |
| 744 | if home, _ := os.UserHomeDir(); home != "" { |
| 745 | if dir == home { |
| 746 | dir = "~" |
| 747 | } else if strings.HasPrefix(dir, home+"/") { |
| 748 | dir = "~" + dir[len(home):] |
| 749 | } |
| 750 | } |
| 751 | |
| 752 | panelW := 64 |
| 753 | pathHeader := hintStyle.Render(fmt.Sprintf(" 📁 %s", dir)) |
| 754 | |
| 755 | border := lipgloss.RoundedBorder() |
| 756 | panel := lipgloss.NewStyle(). |
| 757 | Border(border). |
| 758 | BorderForeground(lipgloss.Color("170")). |
| 759 | Padding(0, 1). |
| 760 | Width(panelW). |
| 761 | Render(pathHeader + "\n" + strings.Repeat("─", panelW-4) + "\n" + m.filePicker.View()) |
| 762 | |
| 763 | b.WriteString(panel) |
| 764 | b.WriteString("\n") |
| 765 | b.WriteString(hintStyle.Render(" ↑/↓/pgup/pgdn navigate • enter/→ open • ←/esc back • q cancel")) |
| 766 | |
| 767 | content := b.String() |
| 768 | if m.width > 0 && m.height > 0 { |
| 769 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 770 | } |
| 771 | return content |
| 772 | } |
| 773 | |
| 774 | func (m Model) showVKProxyModels(models []vkproxy.ProxyModel) (tea.Model, tea.Cmd) { |
| 775 | // Save common config (auth, base_url, env) from first model. |
| 776 | pc := m.cfg.Providers["vkproxy"] |
| 777 | pc.APIKey = models[0].AuthToken |
| 778 | pc.BaseURL = models[0].BaseURL |
| 779 | if pc.Env == nil { |
| 780 | pc.Env = make(map[string]string) |
| 781 | } |
| 782 | if models[0].CustomHeaders != "" { |
| 783 | pc.Env["ANTHROPIC_CUSTOM_HEADERS"] = models[0].CustomHeaders |
| 784 | } |
| 785 | if models[0].ProxyUser != "" { |
| 786 | pc.Env["VK_LLM_PROXY_USER"] = models[0].ProxyUser |
| 787 | } |
| 788 | m.cfg.Providers["vkproxy"] = pc |
| 789 | m.cfg.ActiveProvider = "vkproxy" |
| 790 | |
| 791 | // Group models by provider for the two-column picker. |
| 792 | providers, grouped := vkproxy.GroupByProvider(models) |
| 793 | familyMap := make(map[string][]PickerItem) |
| 794 | for prov, pms := range grouped { |
| 795 | for _, pm := range pms { |
| 796 | familyMap[prov] = append(familyMap[prov], PickerItem{ |
| 797 | ID: pm.ModelID, |
| 798 | Name: pm.Name, |
| 799 | ContextLength: pm.ContextWindow, |
| 800 | }) |
| 801 | } |
| 802 | } |
| 803 | |
| 804 | m.phase = phaseSelectModel |
| 805 | m.pickerProvider = "vkproxy" |
| 806 | m.families = providers |
| 807 | m.familyModels = familyMap |
| 808 | m.preSelectModel() |
| 809 | return m, nil |
| 810 | } |
| 811 | |
| 812 | func (m Model) startModelFetch(oauthToken string) (tea.Model, tea.Cmd) { |
| 813 | m.phase = phaseLoadingModels |
| 814 | m.pickerProvider = "copilot" |
| 815 | cdb := m.cache |
| 816 | return m, func() tea.Msg { |
| 817 | var models []copilot.CopilotModel |
| 818 | if cdb != nil { |
| 819 | if data, ok := cdb.GetModels("copilot"); ok { |
| 820 | if json.Unmarshal(data, &models) != nil { |
| 821 | models = nil |
| 822 | } |
| 823 | } |
| 824 | } |
| 825 | if models == nil { |
| 826 | var err error |
| 827 | models, err = copilot.ListModels(oauthToken) |
| 828 | if err != nil { |
| 829 | return pickerModelsMsg{err: err} |
| 830 | } |
| 831 | if cdb != nil { |
| 832 | if data, err := json.Marshal(models); err == nil { |
| 833 | cdb.SetModels("copilot", data) |
| 834 | } |
| 835 | } |
| 836 | } |
| 837 | families, grouped := copilot.VendorFamilies(models) |
| 838 | items := make(map[string][]PickerItem) |
| 839 | for fam, cms := range grouped { |
| 840 | for _, cm := range cms { |
| 841 | items[fam] = append(items[fam], PickerItem{ID: cm.ID, Name: cm.DisplayName(), ContextLength: cm.ContextWindow}) |
| 842 | } |
| 843 | } |
| 844 | return pickerModelsMsg{families: families, models: items} |
| 845 | } |
| 846 | } |
| 847 | |
| 848 | func (m Model) startORModelFetch(apiKey string) (tea.Model, tea.Cmd) { |
| 849 | m.phase = phaseLoadingModels |
| 850 | m.pickerProvider = "openrouter" |
| 851 | cdb := m.cache |
| 852 | return m, func() tea.Msg { |
| 853 | var models []openrouter.ORModel |
| 854 | if cdb != nil { |
| 855 | if data, ok := cdb.GetModels("openrouter"); ok { |
| 856 | if json.Unmarshal(data, &models) != nil { |
| 857 | models = nil |
| 858 | } else { |
| 859 | models = openrouter.FilterAgentCompatible(models) |
| 860 | } |
| 861 | } |
| 862 | } |
| 863 | if models == nil { |
| 864 | var err error |
| 865 | models, err = openrouter.ListModels(apiKey) |
| 866 | if err != nil { |
| 867 | return pickerModelsMsg{err: err} |
| 868 | } |
| 869 | if cdb != nil { |
| 870 | if data, err := json.Marshal(models); err == nil { |
| 871 | cdb.SetModels("openrouter", data) |
| 872 | } |
| 873 | } |
| 874 | } |
| 875 | |
| 876 | categories, grouped := openrouter.CategorizeModels(models) |
| 877 | items := make(map[string][]PickerItem) |
| 878 | for cat, orms := range grouped { |
| 879 | for _, orm := range orms { |
| 880 | items[cat] = append(items[cat], PickerItem{ID: orm.ID, Name: orm.DisplayName(), ContextLength: orm.ContextLength}) |
| 881 | } |
| 882 | } |
| 883 | |
| 884 | // Prepend "Recently Used" category if any exist. |
| 885 | if cdb != nil { |
| 886 | if used, err := cdb.UsedModels(); err == nil && len(used) > 0 { |
| 887 | var usedItems []PickerItem |
| 888 | for _, u := range used { |
| 889 | usedItems = append(usedItems, PickerItem{ID: u.ID, Name: u.Name, ContextLength: 0}) |
| 890 | } |
| 891 | items["Recently Used"] = usedItems |
| 892 | categories = append([]string{"Recently Used"}, categories...) |
| 893 | } |
| 894 | } |
| 895 | |
| 896 | // Group popular models by provider for drill-down. |
| 897 | provNames, provGrouped := openrouter.GroupPopularByProvider(models) |
| 898 | provItems := make(map[string][]PickerItem) |
| 899 | for prov, orms := range provGrouped { |
| 900 | for _, orm := range orms { |
| 901 | provItems[prov] = append(provItems[prov], PickerItem{ID: orm.ID, Name: orm.DisplayName(), ContextLength: orm.ContextLength}) |
| 902 | } |
| 903 | } |
| 904 | |
| 905 | return pickerModelsMsg{ |
| 906 | families: categories, |
| 907 | models: items, |
| 908 | popularProviders: provNames, |
| 909 | popularProviderModels: provItems, |
| 910 | allModelIDs: openrouter.AllModelIDs(models), |
| 911 | } |
| 912 | } |
| 913 | } |
| 914 | |
| 915 | func (m Model) startNvidiaModelFetch(apiKey string) (tea.Model, tea.Cmd) { |
| 916 | m.phase = phaseLoadingModels |
| 917 | m.pickerProvider = "nvidia" |
| 918 | cdb := m.cache |
| 919 | return m, func() tea.Msg { |
| 920 | var models []nvidia.NIMModel |
| 921 | if cdb != nil { |
| 922 | if data, ok := cdb.GetModels("nvidia"); ok { |
| 923 | if json.Unmarshal(data, &models) != nil { |
| 924 | models = nil |
| 925 | } else { |
| 926 | models = nvidia.FilterChatModels(models) |
| 927 | } |
| 928 | } |
| 929 | } |
| 930 | if models == nil { |
| 931 | var err error |
| 932 | models, err = nvidia.ListModels(apiKey) |
| 933 | if err != nil { |
| 934 | return pickerModelsMsg{err: err} |
| 935 | } |
| 936 | if cdb != nil { |
| 937 | if data, err := json.Marshal(models); err == nil { |
| 938 | cdb.SetModels("nvidia", data) |
| 939 | } |
| 940 | } |
| 941 | } |
| 942 | |
| 943 | categories, grouped := nvidia.CategorizeModels(models) |
| 944 | items := make(map[string][]PickerItem) |
| 945 | for cat, nms := range grouped { |
| 946 | for _, nm := range nms { |
| 947 | items[cat] = append(items[cat], PickerItem{ID: nm.ID, Name: nm.DisplayName()}) |
| 948 | } |
| 949 | } |
| 950 | |
| 951 | // Prepend "Recently Used" category if any exist. |
| 952 | if cdb != nil { |
| 953 | if used, err := cdb.UsedModels(); err == nil && len(used) > 0 { |
| 954 | var usedItems []PickerItem |
| 955 | for _, u := range used { |
| 956 | usedItems = append(usedItems, PickerItem{ID: u.ID, Name: u.Name}) |
| 957 | } |
| 958 | items["Recently Used"] = usedItems |
| 959 | categories = append([]string{"Recently Used"}, categories...) |
| 960 | } |
| 961 | } |
| 962 | |
| 963 | // Group popular models by provider for drill-down. |
| 964 | provNames, provGrouped := nvidia.GroupPopularByProvider(models) |
| 965 | provItems := make(map[string][]PickerItem) |
| 966 | for prov, nms := range provGrouped { |
| 967 | for _, nm := range nms { |
| 968 | provItems[prov] = append(provItems[prov], PickerItem{ID: nm.ID, Name: nm.DisplayName()}) |
| 969 | } |
| 970 | } |
| 971 | |
| 972 | return pickerModelsMsg{ |
| 973 | families: categories, |
| 974 | models: items, |
| 975 | popularProviders: provNames, |
| 976 | popularProviderModels: provItems, |
| 977 | allModelIDs: nvidia.AllModelIDs(models), |
| 978 | } |
| 979 | } |
| 980 | } |
| 981 | |
| 982 | func (m Model) startOllamaModelFetch(apiKey, baseURL string) (tea.Model, tea.Cmd) { |
| 983 | m.phase = phaseLoadingModels |
| 984 | m.pickerProvider = m.selected |
| 985 | cdb := m.cache |
| 986 | provKey := m.selected |
| 987 | return m, func() tea.Msg { |
| 988 | var models []ollamaAPI.OllamaModel |
| 989 | if cdb != nil { |
| 990 | if data, ok := cdb.GetModels(provKey); ok { |
| 991 | if json.Unmarshal(data, &models) != nil { |
| 992 | models = nil |
| 993 | } |
| 994 | } |
| 995 | } |
| 996 | if models == nil { |
| 997 | var err error |
| 998 | models, err = ollamaAPI.ListModels(baseURL, apiKey) |
| 999 | if err != nil { |
| 1000 | return pickerModelsMsg{err: err} |
| 1001 | } |
| 1002 | if cdb != nil { |
| 1003 | if data, err := json.Marshal(models); err == nil { |
| 1004 | cdb.SetModels(provKey, data) |
| 1005 | } |
| 1006 | } |
| 1007 | } |
| 1008 | |
| 1009 | // Fetch context lengths and capabilities via /api/show (cached per model+modified_at). |
| 1010 | ollamaAPI.FetchModelDetails(models, baseURL, apiKey, provKey, cdb) |
| 1011 | |
| 1012 | // For cloud instances, group by provider; |
| 1013 | // for local instances, group by family. |
| 1014 | var families []string |
| 1015 | var grouped map[string][]ollamaAPI.OllamaModel |
| 1016 | if provKey == "ollama-cloud" { |
| 1017 | // Fetch popular model names from ollama.com (cached). |
| 1018 | var popularNames []string |
| 1019 | if cdb != nil { |
| 1020 | if data, ok := cdb.GetModels("ollama-cloud-popular"); ok { |
| 1021 | _ = json.Unmarshal(data, &popularNames) |
| 1022 | } |
| 1023 | } |
| 1024 | if len(popularNames) == 0 { |
| 1025 | if names, err := ollamaAPI.FetchPopularNames(); err == nil && len(names) > 0 { |
| 1026 | popularNames = names |
| 1027 | if cdb != nil { |
| 1028 | if data, err := json.Marshal(popularNames); err == nil { |
| 1029 | cdb.SetModels("ollama-cloud-popular", data) |
| 1030 | } |
| 1031 | } |
| 1032 | } |
| 1033 | } |
| 1034 | families, grouped = ollamaAPI.CategorizeByProvider(models, popularNames) |
| 1035 | } else { |
| 1036 | families, grouped = ollamaAPI.CategorizeModels(models) |
| 1037 | } |
| 1038 | items := make(map[string][]PickerItem) |
| 1039 | for fam, oms := range grouped { |
| 1040 | for _, om := range oms { |
| 1041 | items[fam] = append(items[fam], PickerItem{ID: om.Name, Name: om.DisplayName(), ContextLength: om.ContextLength}) |
| 1042 | } |
| 1043 | } |
| 1044 | |
| 1045 | // Prepend "Recent" category from provider-scoped used models. |
| 1046 | if cdb != nil { |
| 1047 | if used, err := cdb.UsedModelsForProvider(provKey); err == nil && len(used) > 0 { |
| 1048 | var usedItems []PickerItem |
| 1049 | for _, u := range used { |
| 1050 | usedItems = append(usedItems, PickerItem{ID: u.ID, Name: u.Name}) |
| 1051 | } |
| 1052 | items["Recent"] = usedItems |
| 1053 | families = append([]string{"Recent", ollamaAPI.SeparatorCategory}, families...) |
| 1054 | } |
| 1055 | } |
| 1056 | |
| 1057 | return pickerModelsMsg{families: families, models: items} |
| 1058 | } |
| 1059 | } |
| 1060 | |
| 1061 | func (m Model) updateLoadingModels(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 1062 | switch msg := msg.(type) { |
| 1063 | case tea.KeyMsg: |
| 1064 | if msg.String() == "ctrl+c" { |
| 1065 | m.quitting = true |
| 1066 | return m, tea.Quit |
| 1067 | } |
| 1068 | case pickerModelsMsg: |
| 1069 | if msg.err != nil { |
| 1070 | m.modelsErr = msg.err |
| 1071 | m.phase = phaseSelect |
| 1072 | return m, nil |
| 1073 | } |
| 1074 | if len(msg.families) == 0 { |
| 1075 | // No models available — just activate with defaults. |
| 1076 | m.phase = phaseDone |
| 1077 | return m, tea.Quit |
| 1078 | } |
| 1079 | m.families = msg.families |
| 1080 | m.familyModels = msg.models |
| 1081 | m.popularProviders = msg.popularProviders |
| 1082 | m.popularProviderModels = msg.popularProviderModels |
| 1083 | m.allModelIDs = msg.allModelIDs |
| 1084 | m.preSelectModel() |
| 1085 | m.phase = phaseSelectModel |
| 1086 | if m.pickerProvider == "openrouter" || m.pickerProvider == "nvidia" { |
| 1087 | m.initPickerInput() |
| 1088 | } |
| 1089 | return m, nil |
| 1090 | } |
| 1091 | return m, nil |
| 1092 | } |
| 1093 | |
| 1094 | func (m *Model) initPickerInput() { |
| 1095 | pi := textinput.New() |
| 1096 | switch m.pickerProvider { |
| 1097 | case "nvidia": |
| 1098 | pi.Placeholder = "e.g. nvidia/llama-3.1-nemotron-ultra-253b-v1" |
| 1099 | default: |
| 1100 | pi.Placeholder = "e.g. anthropic/claude-sonnet-4" |
| 1101 | } |
| 1102 | pi.CharLimit = 256 |
| 1103 | pi.Width = 40 |
| 1104 | m.pickerInput = pi |
| 1105 | } |
| 1106 | |
| 1107 | func (m Model) currentFamilyModels() []PickerItem { |
| 1108 | if m.familyCursor < len(m.families) { |
| 1109 | return m.familyModels[m.families[m.familyCursor]] |
| 1110 | } |
| 1111 | return nil |
| 1112 | } |
| 1113 | |
| 1114 | // currentRightPaneModels returns the models visible in the right pane, |
| 1115 | // accounting for Popular provider drill-down. |
| 1116 | func (m Model) currentRightPaneModels() []PickerItem { |
| 1117 | if m.isPopularCategory() && m.popularDrilldown && m.popularProviderCursor < len(m.popularProviders) { |
| 1118 | prov := m.popularProviders[m.popularProviderCursor] |
| 1119 | return m.popularProviderModels[prov] |
| 1120 | } |
| 1121 | return m.currentFamilyModels() |
| 1122 | } |
| 1123 | |
| 1124 | func (m Model) isSeparator(idx int) bool { |
| 1125 | return idx < len(m.families) && m.families[idx] == ollamaAPI.SeparatorCategory |
| 1126 | } |
| 1127 | |
| 1128 | func (m Model) isEnterByHand() bool { |
| 1129 | if m.familyCursor < len(m.families) { |
| 1130 | fam := m.families[m.familyCursor] |
| 1131 | return fam == openrouter.EnterByHandCategory || fam == nvidia.EnterByHandCategory |
| 1132 | } |
| 1133 | return false |
| 1134 | } |
| 1135 | |
| 1136 | func (m Model) isPopularCategory() bool { |
| 1137 | if m.familyCursor < len(m.families) { |
| 1138 | return m.families[m.familyCursor] == "Popular" |
| 1139 | } |
| 1140 | return false |
| 1141 | } |
| 1142 | |
| 1143 | // hasPopularDrilldown returns true when the Popular category uses a two-level |
| 1144 | // provider→models drill-down (openrouter, nvidia). For ollama-cloud the |
| 1145 | // Popular models are stored directly in familyModels and need no drill-down. |
| 1146 | func (m Model) hasPopularDrilldown() bool { |
| 1147 | return len(m.popularProviders) > 0 |
| 1148 | } |
| 1149 | |
| 1150 | // pickerVisibleFamilies returns indices into m.families to display. |
| 1151 | // When filtering the left column, returns only matching indices; otherwise all. |
| 1152 | func (m Model) pickerVisibleFamilies() []int { |
| 1153 | if m.pickerFilterActive && !m.pickerFilterRight { |
| 1154 | return m.pickerFilteredIdx |
| 1155 | } |
| 1156 | idx := make([]int, len(m.families)) |
| 1157 | for i := range m.families { |
| 1158 | idx[i] = i |
| 1159 | } |
| 1160 | return idx |
| 1161 | } |
| 1162 | |
| 1163 | // pickerVisibleRightItems returns indices into the right-pane list to display. |
| 1164 | // When filtering the right column, returns only matching indices; otherwise all. |
| 1165 | func (m Model) pickerVisibleRightItems() []int { |
| 1166 | if m.pickerFilterActive && m.pickerFilterRight { |
| 1167 | return m.pickerFilteredIdx |
| 1168 | } |
| 1169 | var n int |
| 1170 | if m.isPopularCategory() && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1171 | n = len(m.popularProviders) |
| 1172 | } else { |
| 1173 | n = len(m.currentRightPaneModels()) |
| 1174 | } |
| 1175 | idx := make([]int, n) |
| 1176 | for i := range idx { |
| 1177 | idx[i] = i |
| 1178 | } |
| 1179 | return idx |
| 1180 | } |
| 1181 | |
| 1182 | func (m *Model) recomputePickerFilter() { |
| 1183 | query := strings.ToLower(m.filterInput.Value()) |
| 1184 | m.pickerFilteredIdx = nil |
| 1185 | if !m.pickerFilterRight { |
| 1186 | for i, fam := range m.families { |
| 1187 | if fam == ollamaAPI.SeparatorCategory { |
| 1188 | continue |
| 1189 | } |
| 1190 | if query == "" || strings.Contains(strings.ToLower(fam), query) { |
| 1191 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 1192 | } |
| 1193 | } |
| 1194 | } else if m.isPopularCategory() && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1195 | for i, prov := range m.popularProviders { |
| 1196 | if query == "" || strings.Contains(strings.ToLower(prov), query) { |
| 1197 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 1198 | } |
| 1199 | } |
| 1200 | } else { |
| 1201 | models := m.currentRightPaneModels() |
| 1202 | for i, item := range models { |
| 1203 | if query == "" || strings.Contains(strings.ToLower(item.DisplayName()), query) || strings.Contains(strings.ToLower(item.ID), query) { |
| 1204 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 1205 | } |
| 1206 | } |
| 1207 | } |
| 1208 | if m.pickerFilterCursor >= len(m.pickerFilteredIdx) { |
| 1209 | m.pickerFilterCursor = max(0, len(m.pickerFilteredIdx)-1) |
| 1210 | } |
| 1211 | } |
| 1212 | |
| 1213 | func (m Model) updateSelectModel(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 1214 | if m.pickerInputActive { |
| 1215 | return m.updatePickerInput(msg) |
| 1216 | } |
| 1217 | if m.pickerFilterActive { |
| 1218 | return m.updateSelectModelFilter(msg) |
| 1219 | } |
| 1220 | isPopular := m.isPopularCategory() |
| 1221 | |
| 1222 | switch msg := msg.(type) { |
| 1223 | case tea.KeyMsg: |
| 1224 | m.statusMsg = "" |
| 1225 | switch msg.String() { |
| 1226 | case "ctrl+c", "q": |
| 1227 | m.quitting = true |
| 1228 | return m, tea.Quit |
| 1229 | case "ctrl+r": |
| 1230 | if m.pickerProvider == "vkproxy" { |
| 1231 | result, cmd := m.startVKProxyPickerWithRefresh(true) |
| 1232 | rm := result.(Model) |
| 1233 | if rm.modelsErr == nil { |
| 1234 | rm.statusMsg = "✓ Refreshed from zip" |
| 1235 | } |
| 1236 | return rm, cmd |
| 1237 | } |
| 1238 | case "esc": |
| 1239 | if m.modelFocus { |
| 1240 | if isPopular && m.hasPopularDrilldown() && m.popularDrilldown { |
| 1241 | m.popularDrilldown = false |
| 1242 | return m, nil |
| 1243 | } |
| 1244 | m.modelFocus = false |
| 1245 | return m, nil |
| 1246 | } |
| 1247 | m.phase = phaseSelect |
| 1248 | return m, nil |
| 1249 | case "up", "k": |
| 1250 | if m.modelFocus { |
| 1251 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1252 | if m.popularProviderCursor > 0 { |
| 1253 | m.popularProviderCursor-- |
| 1254 | } |
| 1255 | } else { |
| 1256 | if m.modelCursor > 0 { |
| 1257 | m.modelCursor-- |
| 1258 | } |
| 1259 | } |
| 1260 | } else { |
| 1261 | if m.familyCursor > 0 { |
| 1262 | m.familyCursor-- |
| 1263 | // Skip separator categories. |
| 1264 | for m.familyCursor > 0 && m.isSeparator(m.familyCursor) { |
| 1265 | m.familyCursor-- |
| 1266 | } |
| 1267 | m.modelCursor = 0 |
| 1268 | m.popularDrilldown = false |
| 1269 | } |
| 1270 | } |
| 1271 | case "down", "j": |
| 1272 | if m.modelFocus { |
| 1273 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1274 | if m.popularProviderCursor < len(m.popularProviders)-1 { |
| 1275 | m.popularProviderCursor++ |
| 1276 | } |
| 1277 | } else { |
| 1278 | models := m.currentRightPaneModels() |
| 1279 | if m.modelCursor < len(models)-1 { |
| 1280 | m.modelCursor++ |
| 1281 | } |
| 1282 | } |
| 1283 | } else { |
| 1284 | if m.familyCursor < len(m.families)-1 { |
| 1285 | m.familyCursor++ |
| 1286 | // Skip separator categories. |
| 1287 | for m.familyCursor < len(m.families)-1 && m.isSeparator(m.familyCursor) { |
| 1288 | m.familyCursor++ |
| 1289 | } |
| 1290 | m.modelCursor = 0 |
| 1291 | m.popularDrilldown = false |
| 1292 | } |
| 1293 | } |
| 1294 | case "/": |
| 1295 | m.pickerFilterActive = true |
| 1296 | m.pickerFilterRight = m.modelFocus |
| 1297 | m.filterInput.SetValue("") |
| 1298 | m.filterInput.Focus() |
| 1299 | m.pickerFilterCursor = 0 |
| 1300 | m.recomputePickerFilter() |
| 1301 | return m, m.filterInput.Cursor.BlinkCmd() |
| 1302 | case "right", "l", "tab": |
| 1303 | if !m.modelFocus { |
| 1304 | m.modelFocus = true |
| 1305 | if m.isEnterByHand() { |
| 1306 | m.pickerInputActive = true |
| 1307 | m.pickerInput.Focus() |
| 1308 | return m, m.pickerInput.Cursor.BlinkCmd() |
| 1309 | } |
| 1310 | } else if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1311 | m.popularDrilldown = true |
| 1312 | m.modelCursor = 0 |
| 1313 | } |
| 1314 | case "left", "h", "shift+tab": |
| 1315 | if m.modelFocus { |
| 1316 | if isPopular && m.hasPopularDrilldown() && m.popularDrilldown { |
| 1317 | m.popularDrilldown = false |
| 1318 | } else { |
| 1319 | m.modelFocus = false |
| 1320 | } |
| 1321 | } |
| 1322 | case "enter": |
| 1323 | if !m.modelFocus { |
| 1324 | if m.isEnterByHand() { |
| 1325 | m.modelFocus = true |
| 1326 | m.pickerInputActive = true |
| 1327 | m.pickerInput.Focus() |
| 1328 | return m, m.pickerInput.Cursor.BlinkCmd() |
| 1329 | } |
| 1330 | m.modelFocus = true |
| 1331 | return m, nil |
| 1332 | } |
| 1333 | // Drill into popular provider. |
| 1334 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1335 | m.popularDrilldown = true |
| 1336 | m.modelCursor = 0 |
| 1337 | return m, nil |
| 1338 | } |
| 1339 | // Select a model. |
| 1340 | models := m.currentRightPaneModels() |
| 1341 | if m.modelCursor < len(models) { |
| 1342 | selected := models[m.modelCursor] |
| 1343 | pc := m.cfg.Providers[m.pickerProvider] |
| 1344 | pc.Model = selected.ID |
| 1345 | pc.ContextWindow = selected.ContextLength |
| 1346 | if m.pickerProvider == "vkproxy" { |
| 1347 | pc.SmallModel = selected.ID |
| 1348 | } |
| 1349 | m.cfg.Providers[m.pickerProvider] = pc |
| 1350 | if m.cache != nil { |
| 1351 | switch m.pickerProvider { |
| 1352 | case "ollama", "ollama-cloud": |
| 1353 | m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName()) |
| 1354 | case "openrouter", "nvidia": |
| 1355 | m.cache.AddUsedModel(selected.ID, selected.DisplayName()) |
| 1356 | } |
| 1357 | } |
| 1358 | m.phase = phaseDone |
| 1359 | return m, tea.Quit |
| 1360 | } |
| 1361 | } |
| 1362 | } |
| 1363 | return m, nil |
| 1364 | } |
| 1365 | |
| 1366 | func (m Model) updateSelectModelFilter(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 1367 | switch msg := msg.(type) { |
| 1368 | case tea.KeyMsg: |
| 1369 | switch msg.String() { |
| 1370 | case "ctrl+c": |
| 1371 | m.quitting = true |
| 1372 | return m, tea.Quit |
| 1373 | case "esc": |
| 1374 | m.pickerFilterActive = false |
| 1375 | return m, nil |
| 1376 | case "up": |
| 1377 | if m.pickerFilterCursor > 0 { |
| 1378 | m.pickerFilterCursor-- |
| 1379 | } |
| 1380 | return m, nil |
| 1381 | case "down": |
| 1382 | if m.pickerFilterCursor < len(m.pickerFilteredIdx)-1 { |
| 1383 | m.pickerFilterCursor++ |
| 1384 | } |
| 1385 | return m, nil |
| 1386 | case "enter": |
| 1387 | if len(m.pickerFilteredIdx) == 0 { |
| 1388 | return m, nil |
| 1389 | } |
| 1390 | actualIdx := m.pickerFilteredIdx[m.pickerFilterCursor] |
| 1391 | m.pickerFilterActive = false |
| 1392 | if !m.pickerFilterRight { |
| 1393 | // Selected a family/category. |
| 1394 | m.familyCursor = actualIdx |
| 1395 | m.modelCursor = 0 |
| 1396 | m.popularDrilldown = false |
| 1397 | m.modelFocus = true |
| 1398 | return m, nil |
| 1399 | } |
| 1400 | if m.isPopularCategory() && !m.popularDrilldown { |
| 1401 | // Selected a popular provider. |
| 1402 | m.popularProviderCursor = actualIdx |
| 1403 | m.popularDrilldown = true |
| 1404 | m.modelCursor = 0 |
| 1405 | return m, nil |
| 1406 | } |
| 1407 | // Selected a model. |
| 1408 | models := m.currentRightPaneModels() |
| 1409 | if actualIdx < len(models) { |
| 1410 | selected := models[actualIdx] |
| 1411 | pc := m.cfg.Providers[m.pickerProvider] |
| 1412 | pc.Model = selected.ID |
| 1413 | pc.ContextWindow = selected.ContextLength |
| 1414 | if m.pickerProvider == "vkproxy" { |
| 1415 | pc.SmallModel = selected.ID |
| 1416 | } |
| 1417 | m.cfg.Providers[m.pickerProvider] = pc |
| 1418 | if m.cache != nil { |
| 1419 | switch m.pickerProvider { |
| 1420 | case "ollama", "ollama-cloud": |
| 1421 | m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName()) |
| 1422 | case "openrouter", "nvidia": |
| 1423 | m.cache.AddUsedModel(selected.ID, selected.DisplayName()) |
| 1424 | } |
| 1425 | } |
| 1426 | m.phase = phaseDone |
| 1427 | return m, tea.Quit |
| 1428 | } |
| 1429 | return m, nil |
| 1430 | } |
| 1431 | } |
| 1432 | var cmd tea.Cmd |
| 1433 | m.filterInput, cmd = m.filterInput.Update(msg) |
| 1434 | m.recomputePickerFilter() |
| 1435 | return m, cmd |
| 1436 | } |
| 1437 | |
| 1438 | func (m Model) updatePickerInput(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 1439 | if msg, ok := msg.(tea.KeyMsg); ok { |
| 1440 | switch msg.String() { |
| 1441 | case "ctrl+c": |
| 1442 | m.quitting = true |
| 1443 | return m, tea.Quit |
| 1444 | case "esc": |
| 1445 | m.pickerInputActive = false |
| 1446 | m.pickerInputErr = "" |
| 1447 | m.modelFocus = false |
| 1448 | return m, nil |
| 1449 | case "enter": |
| 1450 | id := strings.TrimSpace(m.pickerInput.Value()) |
| 1451 | if id == "" { |
| 1452 | return m, nil |
| 1453 | } |
| 1454 | if len(m.allModelIDs) > 0 && !m.allModelIDs[id] { |
| 1455 | m.pickerInputErr = "Model not found" |
| 1456 | return m, nil |
| 1457 | } |
| 1458 | pc := m.cfg.Providers[m.pickerProvider] |
| 1459 | pc.Model = id |
| 1460 | m.cfg.Providers[m.pickerProvider] = pc |
| 1461 | if (m.pickerProvider == "openrouter" || m.pickerProvider == "nvidia") && m.cache != nil { |
| 1462 | m.cache.AddUsedModel(id, id) |
| 1463 | } |
| 1464 | m.phase = phaseDone |
| 1465 | return m, tea.Quit |
| 1466 | default: |
| 1467 | m.pickerInputErr = "" |
| 1468 | } |
| 1469 | } |
| 1470 | var cmd tea.Cmd |
| 1471 | m.pickerInput, cmd = m.pickerInput.Update(msg) |
| 1472 | return m, cmd |
| 1473 | } |
| 1474 | |
| 1475 | // ── styles ─────────────────────────────────────────────────── |
| 1476 | |
| 1477 | var ( |
| 1478 | logoStyle = lipgloss.NewStyle(). |
| 1479 | Foreground(lipgloss.Color("63")). |
| 1480 | Bold(true) |
| 1481 | |
| 1482 | greenBar = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) |
| 1483 | blueBar = lipgloss.NewStyle().Foreground(lipgloss.Color("33")) |
| 1484 | |
| 1485 | cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("170")).Bold(true) |
| 1486 | nameStyle = lipgloss.NewStyle() |
| 1487 | descStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) |
| 1488 | hintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) |
| 1489 | errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) |
| 1490 | promptStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("99")) |
| 1491 | ) |
| 1492 | |
| 1493 | func (m Model) View() string { |
| 1494 | switch m.phase { |
| 1495 | case phaseSelect: |
| 1496 | return m.viewSelect() |
| 1497 | case phaseInputKey: |
| 1498 | return m.viewInput() |
| 1499 | case phaseSelectPlan: |
| 1500 | return m.viewSelectPlan() |
| 1501 | case phaseLoadingModels: |
| 1502 | return m.viewLoadingModels() |
| 1503 | case phaseSelectModel: |
| 1504 | return m.viewSelectModel() |
| 1505 | case phaseFilePicker: |
| 1506 | return m.viewFilePicker() |
| 1507 | default: |
| 1508 | return "" |
| 1509 | } |
| 1510 | } |
| 1511 | |
| 1512 | func (m Model) viewSelect() string { |
| 1513 | var b strings.Builder |
| 1514 | |
| 1515 | b.WriteString(logoStyle.Render(logo)) |
| 1516 | b.WriteString("\n\n") |
| 1517 | |
| 1518 | if m.filterActive { |
| 1519 | b.WriteString(fmt.Sprintf(" / %s\n\n", m.filterInput.View())) |
| 1520 | } |
| 1521 | |
| 1522 | // Determine which entries to display. |
| 1523 | type indexedEntry struct { |
| 1524 | idx int |
| 1525 | entry entry |
| 1526 | } |
| 1527 | var visible []indexedEntry |
| 1528 | if m.filterActive { |
| 1529 | for _, idx := range m.filteredIdx { |
| 1530 | visible = append(visible, indexedEntry{idx: idx, entry: m.entries[idx]}) |
| 1531 | } |
| 1532 | } else { |
| 1533 | for i, e := range m.entries { |
| 1534 | visible = append(visible, indexedEntry{idx: i, entry: e}) |
| 1535 | } |
| 1536 | } |
| 1537 | |
| 1538 | for vi, ie := range visible { |
| 1539 | e := ie.entry |
| 1540 | isActive := e.Key == m.cfg.ActiveProvider |
| 1541 | hasKey := false |
| 1542 | isHidden := false |
| 1543 | if pc, ok := m.cfg.Providers[e.Key]; ok { |
| 1544 | hasKey = pc.APIKey != "" |
| 1545 | isHidden = pc.Hidden |
| 1546 | } |
| 1547 | var isCursor bool |
| 1548 | if m.filterActive { |
| 1549 | isCursor = m.cursor == vi |
| 1550 | } else { |
| 1551 | isCursor = m.cursor == ie.idx |
| 1552 | } |
| 1553 | |
| 1554 | // Cursor indicator. |
| 1555 | cur := " " |
| 1556 | if isCursor { |
| 1557 | cur = cursorStyle.Render("▸ ") |
| 1558 | } |
| 1559 | |
| 1560 | // Status bar: green = active, blue = has key, space = unconfigured. |
| 1561 | bar := " " |
| 1562 | if isActive { |
| 1563 | bar = greenBar.Render("▌ ") |
| 1564 | } else if hasKey { |
| 1565 | bar = blueBar.Render("▌ ") |
| 1566 | } |
| 1567 | |
| 1568 | // Name. |
| 1569 | name := e.Name |
| 1570 | if isHidden { |
| 1571 | name = descStyle.Render(name + " (hidden)") |
| 1572 | } else if isCursor { |
| 1573 | name = cursorStyle.Render(name) |
| 1574 | } |
| 1575 | |
| 1576 | // Description indented under the name. |
| 1577 | desc := descStyle.Render(" " + e.Description) |
| 1578 | |
| 1579 | b.WriteString(fmt.Sprintf(" %s%s%s\n%s\n", cur, bar, name, desc)) |
| 1580 | } |
| 1581 | |
| 1582 | b.WriteString("\n") |
| 1583 | if m.modelsErr != nil { |
| 1584 | b.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ Could not fetch models: %v", m.modelsErr))) |
| 1585 | b.WriteString("\n\n") |
| 1586 | } |
| 1587 | if m.filterActive { |
| 1588 | b.WriteString(hintStyle.Render(" ↑/↓ navigate • enter select • esc clear filter")) |
| 1589 | } else { |
| 1590 | hideHint := "x hide" |
| 1591 | if m.cursor < len(m.entries) { |
| 1592 | if pc, ok := m.cfg.Providers[m.entries[m.cursor].Key]; ok && pc.Hidden { |
| 1593 | hideHint = "x unhide" |
| 1594 | } |
| 1595 | } |
| 1596 | showAllHint := "H show hidden" |
| 1597 | if m.showHidden { |
| 1598 | showAllHint = "H hide hidden" |
| 1599 | } |
| 1600 | b.WriteString(hintStyle.Render(fmt.Sprintf(" ↑/↓ navigate • enter select • e edit key • %s • %s • / filter • q quit", hideHint, showAllHint))) |
| 1601 | } |
| 1602 | |
| 1603 | content := b.String() |
| 1604 | if m.width > 0 && m.height > 0 { |
| 1605 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 1606 | } |
| 1607 | return content |
| 1608 | } |
| 1609 | |
| 1610 | func (m Model) viewInput() string { |
| 1611 | e := m.entries[m.cursor] |
| 1612 | var b strings.Builder |
| 1613 | b.WriteString(logoStyle.Render(logo)) |
| 1614 | b.WriteString("\n\n") |
| 1615 | b.WriteString(promptStyle.Render(fmt.Sprintf(" API Key for %s", e.Name))) |
| 1616 | b.WriteString("\n\n ") |
| 1617 | b.WriteString(m.input.View()) |
| 1618 | b.WriteString("\n\n") |
| 1619 | b.WriteString(hintStyle.Render(" enter confirm • esc back")) |
| 1620 | |
| 1621 | content := b.String() |
| 1622 | if m.width > 0 && m.height > 0 { |
| 1623 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 1624 | } |
| 1625 | return content |
| 1626 | } |
| 1627 | |
| 1628 | func (m Model) pickerLabel() string { |
| 1629 | if reg, ok := provider.Registry[m.pickerProvider]; ok { |
| 1630 | return reg.Name |
| 1631 | } |
| 1632 | return m.pickerProvider |
| 1633 | } |
| 1634 | |
| 1635 | func (m Model) viewLoadingModels() string { |
| 1636 | label := m.pickerLabel() |
| 1637 | var b strings.Builder |
| 1638 | b.WriteString(logoStyle.Render(logo)) |
| 1639 | b.WriteString("\n\n") |
| 1640 | b.WriteString(promptStyle.Render(fmt.Sprintf(" Fetching available %s models...", label))) |
| 1641 | content := b.String() |
| 1642 | if m.width > 0 && m.height > 0 { |
| 1643 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 1644 | } |
| 1645 | return content |
| 1646 | } |
| 1647 | |
| 1648 | const maxVisibleModels = 20 |
| 1649 | |
| 1650 | func (m Model) viewSelectModel() string { |
| 1651 | label := m.pickerLabel() |
| 1652 | |
| 1653 | var b strings.Builder |
| 1654 | b.WriteString(logoStyle.Render(logo)) |
| 1655 | b.WriteString("\n\n") |
| 1656 | b.WriteString(promptStyle.Render(fmt.Sprintf(" Select %s model", label))) |
| 1657 | b.WriteString("\n\n") |
| 1658 | |
| 1659 | if m.pickerFilterActive { |
| 1660 | b.WriteString(fmt.Sprintf(" / %s\n\n", m.filterInput.View())) |
| 1661 | } |
| 1662 | |
| 1663 | isPopular := m.isPopularCategory() |
| 1664 | |
| 1665 | // Determine current model for highlighting. |
| 1666 | pc := m.cfg.Providers[m.pickerProvider] |
| 1667 | currentModel := pc.Model |
| 1668 | if currentModel == "" { |
| 1669 | if reg, ok := provider.Registry[m.pickerProvider]; ok { |
| 1670 | currentModel = reg.Model |
| 1671 | } |
| 1672 | } |
| 1673 | |
| 1674 | // Compute dynamic column widths from content. |
| 1675 | // Left: widest family name + cursor prefix (2). |
| 1676 | leftContentWidth := 0 |
| 1677 | for _, fam := range m.families { |
| 1678 | if len(fam) > leftContentWidth { |
| 1679 | leftContentWidth = len(fam) |
| 1680 | } |
| 1681 | } |
| 1682 | leftContentWidth += 2 // "▸ " prefix |
| 1683 | |
| 1684 | // Right: widest model display name + cursor (2) + bar (2). |
| 1685 | rightContentWidth := 0 |
| 1686 | for _, fam := range m.families { |
| 1687 | for _, item := range m.familyModels[fam] { |
| 1688 | n := lipgloss.Width(item.DisplayName()) |
| 1689 | if n > rightContentWidth { |
| 1690 | rightContentWidth = n |
| 1691 | } |
| 1692 | } |
| 1693 | } |
| 1694 | // Also account for popular provider names and their models. |
| 1695 | for _, prov := range m.popularProviders { |
| 1696 | if len(prov)+2 > rightContentWidth { |
| 1697 | rightContentWidth = len(prov) + 2 |
| 1698 | } |
| 1699 | for _, item := range m.popularProviderModels[prov] { |
| 1700 | n := lipgloss.Width(item.DisplayName()) |
| 1701 | if n > rightContentWidth { |
| 1702 | rightContentWidth = n |
| 1703 | } |
| 1704 | } |
| 1705 | } |
| 1706 | rightContentWidth += 4 // "▸ " prefix + "▌ " bar |
| 1707 | if rightContentWidth < 44 { |
| 1708 | rightContentWidth = 44 |
| 1709 | } |
| 1710 | |
| 1711 | // Width includes padding (1 each side) + border (1 each side). |
| 1712 | leftW := leftContentWidth + 4 |
| 1713 | rightW := rightContentWidth + 4 |
| 1714 | |
| 1715 | // Build left column (families / categories). |
| 1716 | filterLeft := m.pickerFilterActive && !m.pickerFilterRight |
| 1717 | var leftBuf strings.Builder |
| 1718 | for vi, i := range m.pickerVisibleFamilies() { |
| 1719 | fam := m.families[i] |
| 1720 | |
| 1721 | // Render separator lines as non-selectable dividers. |
| 1722 | if fam == ollamaAPI.SeparatorCategory { |
| 1723 | leftBuf.WriteString(hintStyle.Render(" " + fam)) |
| 1724 | leftBuf.WriteString("\n") |
| 1725 | continue |
| 1726 | } |
| 1727 | |
| 1728 | var isCursor bool |
| 1729 | if filterLeft { |
| 1730 | isCursor = m.pickerFilterCursor == vi |
| 1731 | } else { |
| 1732 | isCursor = m.familyCursor == i && !m.modelFocus |
| 1733 | } |
| 1734 | active := m.familyCursor == i |
| 1735 | |
| 1736 | cur := " " |
| 1737 | if isCursor { |
| 1738 | cur = cursorStyle.Render("▸ ") |
| 1739 | } |
| 1740 | |
| 1741 | name := fam |
| 1742 | if isCursor { |
| 1743 | name = cursorStyle.Render(name) |
| 1744 | } else if active { |
| 1745 | name = lipgloss.NewStyle().Bold(true).Render(name) |
| 1746 | } |
| 1747 | |
| 1748 | leftBuf.WriteString(fmt.Sprintf("%s%s\n", cur, name)) |
| 1749 | } |
| 1750 | |
| 1751 | // Build right column. |
| 1752 | var rightBuf strings.Builder |
| 1753 | if m.isEnterByHand() { |
| 1754 | // Show text input for manual model ID entry. |
| 1755 | if m.pickerInputActive { |
| 1756 | rightBuf.WriteString(" Enter model ID:\n\n") |
| 1757 | rightBuf.WriteString(fmt.Sprintf(" %s\n", m.pickerInput.View())) |
| 1758 | if m.pickerInputErr != "" { |
| 1759 | rightBuf.WriteString("\n") |
| 1760 | rightBuf.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ %s", m.pickerInputErr))) |
| 1761 | } |
| 1762 | rightBuf.WriteString("\n") |
| 1763 | rightBuf.WriteString(hintStyle.Render(" enter confirm • esc back")) |
| 1764 | } else { |
| 1765 | rightBuf.WriteString(" Type a model ID manually\n\n") |
| 1766 | rightBuf.WriteString(hintStyle.Render(" press → or enter")) |
| 1767 | } |
| 1768 | } else if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 1769 | // Show provider list for Popular category. |
| 1770 | filterRight := m.pickerFilterActive && m.pickerFilterRight |
| 1771 | for vi, i := range m.pickerVisibleRightItems() { |
| 1772 | prov := m.popularProviders[i] |
| 1773 | var isCursor bool |
| 1774 | if filterRight { |
| 1775 | isCursor = m.pickerFilterCursor == vi |
| 1776 | } else { |
| 1777 | isCursor = m.popularProviderCursor == i && m.modelFocus |
| 1778 | } |
| 1779 | cur := " " |
| 1780 | if isCursor { |
| 1781 | cur = cursorStyle.Render("▸ ") |
| 1782 | } |
| 1783 | name := prov |
| 1784 | if isCursor { |
| 1785 | name = cursorStyle.Render(name) |
| 1786 | } |
| 1787 | rightBuf.WriteString(fmt.Sprintf("%s%s\n", cur, name)) |
| 1788 | } |
| 1789 | } else { |
| 1790 | // Show models (regular categories or drilled-down Popular). |
| 1791 | if isPopular && m.popularDrilldown && m.popularProviderCursor < len(m.popularProviders) { |
| 1792 | rightBuf.WriteString(promptStyle.Render(m.popularProviders[m.popularProviderCursor])) |
| 1793 | rightBuf.WriteString("\n\n") |
| 1794 | } |
| 1795 | |
| 1796 | models := m.currentRightPaneModels() |
| 1797 | filterRight := m.pickerFilterActive && m.pickerFilterRight |
| 1798 | visibleIndices := m.pickerVisibleRightItems() |
| 1799 | total := len(visibleIndices) |
| 1800 | |
| 1801 | // Determine the active cursor position within the visible list. |
| 1802 | activeCursor := m.modelCursor |
| 1803 | if filterRight { |
| 1804 | activeCursor = m.pickerFilterCursor |
| 1805 | } |
| 1806 | |
| 1807 | // Compute visible window for scrolling. |
| 1808 | start := 0 |
| 1809 | if total > maxVisibleModels { |
| 1810 | start = max(activeCursor-maxVisibleModels/2, 0) |
| 1811 | if start+maxVisibleModels > total { |
| 1812 | start = total - maxVisibleModels |
| 1813 | } |
| 1814 | } |
| 1815 | end := min(start+maxVisibleModels, total) |
| 1816 | |
| 1817 | if start > 0 { |
| 1818 | rightBuf.WriteString(hintStyle.Render(fmt.Sprintf(" ↑ %d more", start))) |
| 1819 | rightBuf.WriteString("\n") |
| 1820 | } |
| 1821 | |
| 1822 | for vi := start; vi < end; vi++ { |
| 1823 | realIdx := visibleIndices[vi] |
| 1824 | item := models[realIdx] |
| 1825 | var isCursor bool |
| 1826 | if filterRight { |
| 1827 | isCursor = m.pickerFilterCursor == vi |
| 1828 | } else { |
| 1829 | isCursor = m.modelCursor == realIdx && m.modelFocus |
| 1830 | } |
| 1831 | isActive := item.ID == currentModel |
| 1832 | |
| 1833 | cur := " " |
| 1834 | if isCursor { |
| 1835 | cur = cursorStyle.Render("▸ ") |
| 1836 | } |
| 1837 | |
| 1838 | bar := " " |
| 1839 | if isActive { |
| 1840 | bar = greenBar.Render("▌ ") |
| 1841 | } |
| 1842 | |
| 1843 | name := item.DisplayName() |
| 1844 | if isCursor { |
| 1845 | name = cursorStyle.Render(name) |
| 1846 | } |
| 1847 | |
| 1848 | rightBuf.WriteString(fmt.Sprintf("%s%s%s\n", cur, bar, name)) |
| 1849 | } |
| 1850 | |
| 1851 | if end < total { |
| 1852 | rightBuf.WriteString(hintStyle.Render(fmt.Sprintf(" ↓ %d more", total-end))) |
| 1853 | rightBuf.WriteString("\n") |
| 1854 | } |
| 1855 | } |
| 1856 | |
| 1857 | borderColor := lipgloss.Color("241") |
| 1858 | activeBorderColor := lipgloss.Color("170") |
| 1859 | |
| 1860 | leftBorder := lipgloss.RoundedBorder() |
| 1861 | leftBorderColor := borderColor |
| 1862 | if !m.modelFocus { |
| 1863 | leftBorderColor = activeBorderColor |
| 1864 | } |
| 1865 | leftPanel := lipgloss.NewStyle(). |
| 1866 | Border(leftBorder). |
| 1867 | BorderForeground(leftBorderColor). |
| 1868 | Padding(0, 1). |
| 1869 | Width(leftW). |
| 1870 | Render(strings.TrimRight(leftBuf.String(), "\n")) |
| 1871 | |
| 1872 | rightBorder := lipgloss.RoundedBorder() |
| 1873 | rightBorderColor := borderColor |
| 1874 | if m.modelFocus { |
| 1875 | rightBorderColor = activeBorderColor |
| 1876 | } |
| 1877 | rightPanel := lipgloss.NewStyle(). |
| 1878 | Border(rightBorder). |
| 1879 | BorderForeground(rightBorderColor). |
| 1880 | Padding(0, 1). |
| 1881 | Width(rightW). |
| 1882 | Render(strings.TrimRight(rightBuf.String(), "\n")) |
| 1883 | |
| 1884 | b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel)) |
| 1885 | |
| 1886 | b.WriteString("\n") |
| 1887 | hint := " ←/→ switch column • ↑/↓ navigate • enter select • / filter • esc back" |
| 1888 | if m.pickerProvider == "vkproxy" { |
| 1889 | hint += " • ctrl+r refresh" |
| 1890 | } |
| 1891 | if m.pickerFilterActive { |
| 1892 | hint = " ↑/↓ navigate • enter select • esc clear filter" |
| 1893 | } else if m.pickerInputActive { |
| 1894 | hint = " enter confirm • esc back" |
| 1895 | } else if isPopular && m.hasPopularDrilldown() && m.modelFocus && !m.popularDrilldown { |
| 1896 | hint = " →/enter select provider • ↑/↓ navigate • / filter • ← back" |
| 1897 | } else if isPopular && m.hasPopularDrilldown() && m.modelFocus && m.popularDrilldown { |
| 1898 | hint = " enter select model • ↑/↓ navigate • / filter • ←/esc providers" |
| 1899 | } |
| 1900 | b.WriteString(hintStyle.Render(hint)) |
| 1901 | if m.statusMsg != "" { |
| 1902 | b.WriteString("\n") |
| 1903 | b.WriteString(greenBar.Render(" " + m.statusMsg)) |
| 1904 | } |
| 1905 | |
| 1906 | content := b.String() |
| 1907 | if m.width > 0 && m.height > 0 { |
| 1908 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 1909 | } |
| 1910 | return content |
| 1911 | } |
| 1912 | |