| 1 | package tui |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "strings" |
| 6 | |
| 7 | tea "github.com/charmbracelet/bubbletea" |
| 8 | "github.com/charmbracelet/lipgloss" |
| 9 | "sourcecraft.dev/bigbes/claudio/nvidia" |
| 10 | "sourcecraft.dev/bigbes/claudio/ollama" |
| 11 | "sourcecraft.dev/bigbes/claudio/openrouter" |
| 12 | "sourcecraft.dev/bigbes/claudio/provider" |
| 13 | ) |
| 14 | |
| 15 | const maxVisibleModels = 20 |
| 16 | |
| 17 | func (m Model) currentFamilyModels() []PickerItem { |
| 18 | if m.familyCursor < len(m.families) { |
| 19 | return m.familyModels[m.families[m.familyCursor]] |
| 20 | } |
| 21 | return nil |
| 22 | } |
| 23 | |
| 24 | // currentRightPaneModels returns the models visible in the right pane, |
| 25 | // accounting for Popular provider drill-down. |
| 26 | func (m Model) currentRightPaneModels() []PickerItem { |
| 27 | if m.isPopularCategory() && m.popularDrilldown && m.popularProviderCursor < len(m.popularProviders) { |
| 28 | prov := m.popularProviders[m.popularProviderCursor] |
| 29 | return m.popularProviderModels[prov] |
| 30 | } |
| 31 | return m.currentFamilyModels() |
| 32 | } |
| 33 | |
| 34 | func (m Model) isSeparator(idx int) bool { |
| 35 | return idx < len(m.families) && m.families[idx] == ollama.SeparatorCategory |
| 36 | } |
| 37 | |
| 38 | func (m Model) isEnterByHand() bool { |
| 39 | if m.familyCursor < len(m.families) { |
| 40 | fam := m.families[m.familyCursor] |
| 41 | return fam == openrouter.EnterByHandCategory || fam == nvidia.EnterByHandCategory |
| 42 | } |
| 43 | return false |
| 44 | } |
| 45 | |
| 46 | func (m Model) isPopularCategory() bool { |
| 47 | if m.familyCursor < len(m.families) { |
| 48 | return m.families[m.familyCursor] == "Popular" |
| 49 | } |
| 50 | return false |
| 51 | } |
| 52 | |
| 53 | // hasPopularDrilldown returns true when the Popular category uses a two-level |
| 54 | // provider→models drill-down (openrouter, nvidia). For ollama-cloud the |
| 55 | // Popular models are stored directly in familyModels and need no drill-down. |
| 56 | func (m Model) hasPopularDrilldown() bool { |
| 57 | return len(m.popularProviders) > 0 |
| 58 | } |
| 59 | |
| 60 | // pickerVisibleFamilies returns indices into m.families to display. |
| 61 | // When filtering the left column, returns only matching indices; otherwise all. |
| 62 | func (m Model) pickerVisibleFamilies() []int { |
| 63 | if m.pickerFilterActive && !m.pickerFilterRight { |
| 64 | return m.pickerFilteredIdx |
| 65 | } |
| 66 | idx := make([]int, len(m.families)) |
| 67 | for i := range m.families { |
| 68 | idx[i] = i |
| 69 | } |
| 70 | return idx |
| 71 | } |
| 72 | |
| 73 | // pickerVisibleRightItems returns indices into the right-pane list to display. |
| 74 | // When filtering the right column, returns only matching indices; otherwise all. |
| 75 | func (m Model) pickerVisibleRightItems() []int { |
| 76 | if m.pickerFilterActive && m.pickerFilterRight { |
| 77 | return m.pickerFilteredIdx |
| 78 | } |
| 79 | var n int |
| 80 | if m.isPopularCategory() && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 81 | n = len(m.popularProviders) |
| 82 | } else { |
| 83 | n = len(m.currentRightPaneModels()) |
| 84 | } |
| 85 | idx := make([]int, n) |
| 86 | for i := range idx { |
| 87 | idx[i] = i |
| 88 | } |
| 89 | return idx |
| 90 | } |
| 91 | |
| 92 | func (m *Model) recomputePickerFilter() { |
| 93 | query := strings.ToLower(m.filterInput.Value()) |
| 94 | m.pickerFilteredIdx = nil |
| 95 | if !m.pickerFilterRight { |
| 96 | for i, fam := range m.families { |
| 97 | if fam == ollama.SeparatorCategory { |
| 98 | continue |
| 99 | } |
| 100 | if query == "" || strings.Contains(strings.ToLower(fam), query) { |
| 101 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 102 | } |
| 103 | } |
| 104 | } else if m.isPopularCategory() && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 105 | for i, prov := range m.popularProviders { |
| 106 | if query == "" || strings.Contains(strings.ToLower(prov), query) { |
| 107 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 108 | } |
| 109 | } |
| 110 | } else { |
| 111 | models := m.currentRightPaneModels() |
| 112 | for i, item := range models { |
| 113 | if query == "" || strings.Contains(strings.ToLower(item.DisplayName()), query) || strings.Contains(strings.ToLower(item.ID), query) { |
| 114 | m.pickerFilteredIdx = append(m.pickerFilteredIdx, i) |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | if m.pickerFilterCursor >= len(m.pickerFilteredIdx) { |
| 119 | m.pickerFilterCursor = max(0, len(m.pickerFilteredIdx)-1) |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | func (m Model) updateSelectModel(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 124 | if m.pickerInputActive { |
| 125 | return m.updatePickerInput(msg) |
| 126 | } |
| 127 | if m.pickerFilterActive { |
| 128 | return m.updateSelectModelFilter(msg) |
| 129 | } |
| 130 | isPopular := m.isPopularCategory() |
| 131 | |
| 132 | switch msg := msg.(type) { |
| 133 | case tea.KeyMsg: |
| 134 | m.statusMsg = "" |
| 135 | switch msg.String() { |
| 136 | case "ctrl+c", "q": |
| 137 | m.quitting = true |
| 138 | return m, tea.Quit |
| 139 | case "ctrl+r": |
| 140 | if m.pickerProvider == "vkproxy" { |
| 141 | result, cmd := m.startVKProxyPickerWithRefresh(true) |
| 142 | rm := result.(Model) |
| 143 | if rm.modelsErr == nil { |
| 144 | rm.statusMsg = "✓ Refreshed from zip" |
| 145 | } |
| 146 | return rm, cmd |
| 147 | } |
| 148 | case "esc": |
| 149 | if m.modelFocus { |
| 150 | if isPopular && m.hasPopularDrilldown() && m.popularDrilldown { |
| 151 | m.popularDrilldown = false |
| 152 | return m, nil |
| 153 | } |
| 154 | m.modelFocus = false |
| 155 | return m, nil |
| 156 | } |
| 157 | m.phase = phaseSelect |
| 158 | return m, nil |
| 159 | case "up", "k": |
| 160 | if m.modelFocus { |
| 161 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 162 | if m.popularProviderCursor > 0 { |
| 163 | m.popularProviderCursor-- |
| 164 | } |
| 165 | } else { |
| 166 | if m.modelCursor > 0 { |
| 167 | m.modelCursor-- |
| 168 | } |
| 169 | } |
| 170 | } else { |
| 171 | if m.familyCursor > 0 { |
| 172 | m.familyCursor-- |
| 173 | // Skip separator categories. |
| 174 | for m.familyCursor > 0 && m.isSeparator(m.familyCursor) { |
| 175 | m.familyCursor-- |
| 176 | } |
| 177 | m.modelCursor = 0 |
| 178 | m.popularDrilldown = false |
| 179 | } |
| 180 | } |
| 181 | case "down", "j": |
| 182 | if m.modelFocus { |
| 183 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 184 | if m.popularProviderCursor < len(m.popularProviders)-1 { |
| 185 | m.popularProviderCursor++ |
| 186 | } |
| 187 | } else { |
| 188 | models := m.currentRightPaneModels() |
| 189 | if m.modelCursor < len(models)-1 { |
| 190 | m.modelCursor++ |
| 191 | } |
| 192 | } |
| 193 | } else { |
| 194 | if m.familyCursor < len(m.families)-1 { |
| 195 | m.familyCursor++ |
| 196 | // Skip separator categories. |
| 197 | for m.familyCursor < len(m.families)-1 && m.isSeparator(m.familyCursor) { |
| 198 | m.familyCursor++ |
| 199 | } |
| 200 | m.modelCursor = 0 |
| 201 | m.popularDrilldown = false |
| 202 | } |
| 203 | } |
| 204 | case "/": |
| 205 | m.pickerFilterActive = true |
| 206 | m.pickerFilterRight = m.modelFocus |
| 207 | m.filterInput.SetValue("") |
| 208 | m.filterInput.Focus() |
| 209 | m.pickerFilterCursor = 0 |
| 210 | m.recomputePickerFilter() |
| 211 | return m, m.filterInput.Cursor.BlinkCmd() |
| 212 | case "right", "l", "tab": |
| 213 | if !m.modelFocus { |
| 214 | m.modelFocus = true |
| 215 | if m.isEnterByHand() { |
| 216 | m.pickerInputActive = true |
| 217 | m.pickerInput.Focus() |
| 218 | return m, m.pickerInput.Cursor.BlinkCmd() |
| 219 | } |
| 220 | } else if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 221 | m.popularDrilldown = true |
| 222 | m.modelCursor = 0 |
| 223 | } |
| 224 | case "left", "h", "shift+tab": |
| 225 | if m.modelFocus { |
| 226 | if isPopular && m.hasPopularDrilldown() && m.popularDrilldown { |
| 227 | m.popularDrilldown = false |
| 228 | } else { |
| 229 | m.modelFocus = false |
| 230 | } |
| 231 | } |
| 232 | case "enter": |
| 233 | if !m.modelFocus { |
| 234 | if m.isEnterByHand() { |
| 235 | m.modelFocus = true |
| 236 | m.pickerInputActive = true |
| 237 | m.pickerInput.Focus() |
| 238 | return m, m.pickerInput.Cursor.BlinkCmd() |
| 239 | } |
| 240 | m.modelFocus = true |
| 241 | return m, nil |
| 242 | } |
| 243 | // Drill into popular provider. |
| 244 | if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 245 | m.popularDrilldown = true |
| 246 | m.modelCursor = 0 |
| 247 | return m, nil |
| 248 | } |
| 249 | // Select a model. |
| 250 | models := m.currentRightPaneModels() |
| 251 | if m.modelCursor < len(models) { |
| 252 | selected := models[m.modelCursor] |
| 253 | pc := m.cfg.Providers[m.pickerProvider] |
| 254 | pc.Model = selected.ID |
| 255 | pc.ContextWindow = selected.ContextLength |
| 256 | if m.pickerProvider == "vkproxy" { |
| 257 | pc.SmallModel = selected.ID |
| 258 | } |
| 259 | m.cfg.Providers[m.pickerProvider] = pc |
| 260 | if m.cache != nil { |
| 261 | switch m.pickerProvider { |
| 262 | case "ollama", "ollama-cloud", "lmstudio", "openrouter", "nvidia", "mistral": |
| 263 | m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName()) |
| 264 | } |
| 265 | } |
| 266 | m.phase = phaseDone |
| 267 | m.launchAfterConfig = true |
| 268 | return m, tea.Quit |
| 269 | } |
| 270 | } |
| 271 | } |
| 272 | return m, nil |
| 273 | } |
| 274 | |
| 275 | func (m Model) updateSelectModelFilter(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 276 | switch msg := msg.(type) { |
| 277 | case tea.KeyMsg: |
| 278 | switch msg.String() { |
| 279 | case "ctrl+c": |
| 280 | m.quitting = true |
| 281 | return m, tea.Quit |
| 282 | case "esc": |
| 283 | m.pickerFilterActive = false |
| 284 | return m, nil |
| 285 | case "up": |
| 286 | if m.pickerFilterCursor > 0 { |
| 287 | m.pickerFilterCursor-- |
| 288 | } |
| 289 | return m, nil |
| 290 | case "down": |
| 291 | if m.pickerFilterCursor < len(m.pickerFilteredIdx)-1 { |
| 292 | m.pickerFilterCursor++ |
| 293 | } |
| 294 | return m, nil |
| 295 | case "enter": |
| 296 | if len(m.pickerFilteredIdx) == 0 { |
| 297 | return m, nil |
| 298 | } |
| 299 | actualIdx := m.pickerFilteredIdx[m.pickerFilterCursor] |
| 300 | m.pickerFilterActive = false |
| 301 | if !m.pickerFilterRight { |
| 302 | // Selected a family/category. |
| 303 | m.familyCursor = actualIdx |
| 304 | m.modelCursor = 0 |
| 305 | m.popularDrilldown = false |
| 306 | m.modelFocus = true |
| 307 | return m, nil |
| 308 | } |
| 309 | if m.isPopularCategory() && !m.popularDrilldown { |
| 310 | // Selected a popular provider. |
| 311 | m.popularProviderCursor = actualIdx |
| 312 | m.popularDrilldown = true |
| 313 | m.modelCursor = 0 |
| 314 | return m, nil |
| 315 | } |
| 316 | // Selected a model. |
| 317 | models := m.currentRightPaneModels() |
| 318 | if actualIdx < len(models) { |
| 319 | selected := models[actualIdx] |
| 320 | pc := m.cfg.Providers[m.pickerProvider] |
| 321 | pc.Model = selected.ID |
| 322 | pc.ContextWindow = selected.ContextLength |
| 323 | if m.pickerProvider == "vkproxy" { |
| 324 | pc.SmallModel = selected.ID |
| 325 | } |
| 326 | m.cfg.Providers[m.pickerProvider] = pc |
| 327 | if m.cache != nil { |
| 328 | switch m.pickerProvider { |
| 329 | case "ollama", "ollama-cloud", "lmstudio", "openrouter", "nvidia", "mistral": |
| 330 | m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName()) |
| 331 | } |
| 332 | } |
| 333 | m.phase = phaseDone |
| 334 | m.launchAfterConfig = true |
| 335 | return m, tea.Quit |
| 336 | } |
| 337 | return m, nil |
| 338 | } |
| 339 | } |
| 340 | var cmd tea.Cmd |
| 341 | m.filterInput, cmd = m.filterInput.Update(msg) |
| 342 | m.recomputePickerFilter() |
| 343 | return m, cmd |
| 344 | } |
| 345 | |
| 346 | func (m Model) updatePickerInput(msg tea.Msg) (tea.Model, tea.Cmd) { |
| 347 | if msg, ok := msg.(tea.KeyMsg); ok { |
| 348 | switch msg.String() { |
| 349 | case "ctrl+c": |
| 350 | m.quitting = true |
| 351 | return m, tea.Quit |
| 352 | case "esc": |
| 353 | m.pickerInputActive = false |
| 354 | m.pickerInputErr = "" |
| 355 | m.modelFocus = false |
| 356 | return m, nil |
| 357 | case "enter": |
| 358 | id := strings.TrimSpace(m.pickerInput.Value()) |
| 359 | if id == "" { |
| 360 | return m, nil |
| 361 | } |
| 362 | if len(m.allModelIDs) > 0 && !m.allModelIDs[id] { |
| 363 | m.pickerInputErr = "Model not found" |
| 364 | return m, nil |
| 365 | } |
| 366 | pc := m.cfg.Providers[m.pickerProvider] |
| 367 | pc.Model = id |
| 368 | m.cfg.Providers[m.pickerProvider] = pc |
| 369 | if (m.pickerProvider == "openrouter" || m.pickerProvider == "nvidia") && m.cache != nil { |
| 370 | m.cache.AddUsedModelForProvider(m.pickerProvider, id, id) |
| 371 | } |
| 372 | m.phase = phaseDone |
| 373 | m.launchAfterConfig = true |
| 374 | return m, tea.Quit |
| 375 | default: |
| 376 | m.pickerInputErr = "" |
| 377 | } |
| 378 | } |
| 379 | var cmd tea.Cmd |
| 380 | m.pickerInput, cmd = m.pickerInput.Update(msg) |
| 381 | return m, cmd |
| 382 | } |
| 383 | |
| 384 | func (m Model) viewSelectModel() string { |
| 385 | label := m.pickerLabel() |
| 386 | |
| 387 | var b strings.Builder |
| 388 | b.WriteString(logoStyle.Render(logo)) |
| 389 | b.WriteString("\n\n") |
| 390 | b.WriteString(promptStyle.Render(fmt.Sprintf(" Select %s model", label))) |
| 391 | b.WriteString("\n\n") |
| 392 | |
| 393 | if m.pickerFilterActive { |
| 394 | b.WriteString(fmt.Sprintf(" / %s\n\n", m.filterInput.View())) |
| 395 | } |
| 396 | |
| 397 | isPopular := m.isPopularCategory() |
| 398 | |
| 399 | // Determine current model for highlighting. |
| 400 | pc := m.cfg.Providers[m.pickerProvider] |
| 401 | currentModel := pc.Model |
| 402 | if currentModel == "" { |
| 403 | if reg, ok := provider.Registry[m.pickerProvider]; ok { |
| 404 | currentModel = reg.Model |
| 405 | } |
| 406 | } |
| 407 | |
| 408 | // Compute dynamic column widths from content. |
| 409 | // Left: widest family name + cursor prefix (2). |
| 410 | leftContentWidth := 0 |
| 411 | for _, fam := range m.families { |
| 412 | if len(fam) > leftContentWidth { |
| 413 | leftContentWidth = len(fam) |
| 414 | } |
| 415 | } |
| 416 | leftContentWidth += 2 // "▸ " prefix |
| 417 | |
| 418 | // Right: widest model display name + cursor (2) + bar (2). |
| 419 | rightContentWidth := 0 |
| 420 | for _, fam := range m.families { |
| 421 | for _, item := range m.familyModels[fam] { |
| 422 | n := lipgloss.Width(item.DisplayName()) |
| 423 | if n > rightContentWidth { |
| 424 | rightContentWidth = n |
| 425 | } |
| 426 | } |
| 427 | } |
| 428 | // Also account for popular provider names and their models. |
| 429 | for _, prov := range m.popularProviders { |
| 430 | if len(prov)+2 > rightContentWidth { |
| 431 | rightContentWidth = len(prov) + 2 |
| 432 | } |
| 433 | for _, item := range m.popularProviderModels[prov] { |
| 434 | n := lipgloss.Width(item.DisplayName()) |
| 435 | if n > rightContentWidth { |
| 436 | rightContentWidth = n |
| 437 | } |
| 438 | } |
| 439 | } |
| 440 | rightContentWidth += 4 // "▸ " prefix + "▌ " bar |
| 441 | if rightContentWidth < 44 { |
| 442 | rightContentWidth = 44 |
| 443 | } |
| 444 | |
| 445 | // Width includes padding (1 each side) + border (1 each side). |
| 446 | leftW := leftContentWidth + 4 |
| 447 | rightW := rightContentWidth + 4 |
| 448 | |
| 449 | // Build left column (families / categories). |
| 450 | filterLeft := m.pickerFilterActive && !m.pickerFilterRight |
| 451 | var leftBuf strings.Builder |
| 452 | for vi, i := range m.pickerVisibleFamilies() { |
| 453 | fam := m.families[i] |
| 454 | |
| 455 | // Render separator lines as non-selectable dividers. |
| 456 | if fam == ollama.SeparatorCategory { |
| 457 | leftBuf.WriteString(hintStyle.Render(" " + fam)) |
| 458 | leftBuf.WriteString("\n") |
| 459 | continue |
| 460 | } |
| 461 | |
| 462 | var isCursor bool |
| 463 | if filterLeft { |
| 464 | isCursor = m.pickerFilterCursor == vi |
| 465 | } else { |
| 466 | isCursor = m.familyCursor == i && !m.modelFocus |
| 467 | } |
| 468 | active := m.familyCursor == i |
| 469 | |
| 470 | cur := " " |
| 471 | if isCursor { |
| 472 | cur = cursorStyle.Render("▸ ") |
| 473 | } |
| 474 | |
| 475 | name := fam |
| 476 | if isCursor { |
| 477 | name = cursorStyle.Render(name) |
| 478 | } else if active { |
| 479 | name = lipgloss.NewStyle().Bold(true).Render(name) |
| 480 | } |
| 481 | |
| 482 | leftBuf.WriteString(fmt.Sprintf("%s%s\n", cur, name)) |
| 483 | } |
| 484 | |
| 485 | // Build right column. |
| 486 | var rightBuf strings.Builder |
| 487 | if m.isEnterByHand() { |
| 488 | // Show text input for manual model ID entry. |
| 489 | if m.pickerInputActive { |
| 490 | rightBuf.WriteString(" Enter model ID:\n\n") |
| 491 | rightBuf.WriteString(fmt.Sprintf(" %s\n", m.pickerInput.View())) |
| 492 | if m.pickerInputErr != "" { |
| 493 | rightBuf.WriteString("\n") |
| 494 | rightBuf.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ %s", m.pickerInputErr))) |
| 495 | } |
| 496 | rightBuf.WriteString("\n") |
| 497 | rightBuf.WriteString(hintStyle.Render(" enter confirm • esc back")) |
| 498 | } else { |
| 499 | rightBuf.WriteString(" Type a model ID manually\n\n") |
| 500 | rightBuf.WriteString(hintStyle.Render(" press → or enter")) |
| 501 | } |
| 502 | } else if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown { |
| 503 | // Show provider list for Popular category. |
| 504 | filterRight := m.pickerFilterActive && m.pickerFilterRight |
| 505 | for vi, i := range m.pickerVisibleRightItems() { |
| 506 | prov := m.popularProviders[i] |
| 507 | var isCursor bool |
| 508 | if filterRight { |
| 509 | isCursor = m.pickerFilterCursor == vi |
| 510 | } else { |
| 511 | isCursor = m.popularProviderCursor == i && m.modelFocus |
| 512 | } |
| 513 | cur := " " |
| 514 | if isCursor { |
| 515 | cur = cursorStyle.Render("▸ ") |
| 516 | } |
| 517 | name := prov |
| 518 | if isCursor { |
| 519 | name = cursorStyle.Render(name) |
| 520 | } |
| 521 | rightBuf.WriteString(fmt.Sprintf("%s%s\n", cur, name)) |
| 522 | } |
| 523 | } else { |
| 524 | // Show models (regular categories or drilled-down Popular). |
| 525 | if isPopular && m.popularDrilldown && m.popularProviderCursor < len(m.popularProviders) { |
| 526 | rightBuf.WriteString(promptStyle.Render(m.popularProviders[m.popularProviderCursor])) |
| 527 | rightBuf.WriteString("\n\n") |
| 528 | } |
| 529 | |
| 530 | models := m.currentRightPaneModels() |
| 531 | filterRight := m.pickerFilterActive && m.pickerFilterRight |
| 532 | visibleIndices := m.pickerVisibleRightItems() |
| 533 | total := len(visibleIndices) |
| 534 | |
| 535 | // Determine the active cursor position within the visible list. |
| 536 | activeCursor := m.modelCursor |
| 537 | if filterRight { |
| 538 | activeCursor = m.pickerFilterCursor |
| 539 | } |
| 540 | |
| 541 | // Compute visible window for scrolling. |
| 542 | start := 0 |
| 543 | if total > maxVisibleModels { |
| 544 | start = max(activeCursor-maxVisibleModels/2, 0) |
| 545 | if start+maxVisibleModels > total { |
| 546 | start = total - maxVisibleModels |
| 547 | } |
| 548 | } |
| 549 | end := min(start+maxVisibleModels, total) |
| 550 | |
| 551 | if start > 0 { |
| 552 | rightBuf.WriteString(hintStyle.Render(fmt.Sprintf(" ↑ %d more", start))) |
| 553 | rightBuf.WriteString("\n") |
| 554 | } |
| 555 | |
| 556 | for vi := start; vi < end; vi++ { |
| 557 | realIdx := visibleIndices[vi] |
| 558 | item := models[realIdx] |
| 559 | var isCursor bool |
| 560 | if filterRight { |
| 561 | isCursor = m.pickerFilterCursor == vi |
| 562 | } else { |
| 563 | isCursor = m.modelCursor == realIdx && m.modelFocus |
| 564 | } |
| 565 | isActive := item.ID == currentModel |
| 566 | |
| 567 | cur := " " |
| 568 | if isCursor { |
| 569 | cur = cursorStyle.Render("▸ ") |
| 570 | } |
| 571 | |
| 572 | bar := " " |
| 573 | if isActive { |
| 574 | bar = greenBar.Render("▌ ") |
| 575 | } |
| 576 | |
| 577 | name := item.DisplayName() |
| 578 | if isCursor { |
| 579 | name = cursorStyle.Render(name) |
| 580 | } |
| 581 | |
| 582 | rightBuf.WriteString(fmt.Sprintf("%s%s%s\n", cur, bar, name)) |
| 583 | } |
| 584 | |
| 585 | if end < total { |
| 586 | rightBuf.WriteString(hintStyle.Render(fmt.Sprintf(" ↓ %d more", total-end))) |
| 587 | rightBuf.WriteString("\n") |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | borderColor := lipgloss.Color("241") |
| 592 | activeBorderColor := lipgloss.Color("170") |
| 593 | |
| 594 | leftBorder := lipgloss.RoundedBorder() |
| 595 | leftBorderColor := borderColor |
| 596 | if !m.modelFocus { |
| 597 | leftBorderColor = activeBorderColor |
| 598 | } |
| 599 | leftPanel := lipgloss.NewStyle(). |
| 600 | Border(leftBorder). |
| 601 | BorderForeground(leftBorderColor). |
| 602 | Padding(0, 1). |
| 603 | Width(leftW). |
| 604 | Render(strings.TrimRight(leftBuf.String(), "\n")) |
| 605 | |
| 606 | rightBorder := lipgloss.RoundedBorder() |
| 607 | rightBorderColor := borderColor |
| 608 | if m.modelFocus { |
| 609 | rightBorderColor = activeBorderColor |
| 610 | } |
| 611 | rightPanel := lipgloss.NewStyle(). |
| 612 | Border(rightBorder). |
| 613 | BorderForeground(rightBorderColor). |
| 614 | Padding(0, 1). |
| 615 | Width(rightW). |
| 616 | Render(strings.TrimRight(rightBuf.String(), "\n")) |
| 617 | |
| 618 | b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel)) |
| 619 | |
| 620 | b.WriteString("\n") |
| 621 | hint := " ←/→ switch column • ↑/↓ navigate • enter select • / filter • esc back" |
| 622 | if m.pickerProvider == "vkproxy" { |
| 623 | hint += " • ctrl+r refresh" |
| 624 | } |
| 625 | if m.pickerFilterActive { |
| 626 | hint = " ↑/↓ navigate • enter select • esc clear filter" |
| 627 | } else if m.pickerInputActive { |
| 628 | hint = " enter confirm • esc back" |
| 629 | } else if isPopular && m.hasPopularDrilldown() && m.modelFocus && !m.popularDrilldown { |
| 630 | hint = " →/enter select provider • ↑/↓ navigate • / filter • ← back" |
| 631 | } else if isPopular && m.hasPopularDrilldown() && m.modelFocus && m.popularDrilldown { |
| 632 | hint = " enter select model • ↑/↓ navigate • / filter • ←/esc providers" |
| 633 | } |
| 634 | b.WriteString(hintStyle.Render(hint)) |
| 635 | if m.statusMsg != "" { |
| 636 | b.WriteString("\n") |
| 637 | b.WriteString(greenBar.Render(" " + m.statusMsg)) |
| 638 | } |
| 639 | |
| 640 | content := b.String() |
| 641 | if m.width > 0 && m.height > 0 { |
| 642 | return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content) |
| 643 | } |
| 644 | return content |
| 645 | } |
| 646 | |