model_select.go

v0.6.2
Doc Versions Source
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/provider"
10
	"sourcecraft.dev/bigbes/claudio/provider/nvidia"
11
	"sourcecraft.dev/bigbes/claudio/provider/ollama"
12
	"sourcecraft.dev/bigbes/claudio/provider/openrouter"
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
		fmt.Fprintf(&b, " / %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
		fmt.Fprintf(&leftBuf, "%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
			fmt.Fprintf(&rightBuf, " %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
			fmt.Fprintf(&rightBuf, "%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
			fmt.Fprintf(&rightBuf, "%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

Source Files