tui.go

v0.2.0
Doc Versions Source
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

Source Files