model_select.go

v0.7.0
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
					pc = m.applyVKProxyEnv(pc, selected.ID)
259
				}
260
				m.cfg.Providers[m.pickerProvider] = pc
261
				if m.cache != nil {
262
					switch m.pickerProvider {
263
					case "ollama", "ollama-cloud", "lmstudio", "openrouter", "nvidia", "mistral":
264
						_ = m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName())
265
					}
266
				}
267
				m.phase = phaseDone
268
				m.launchAfterConfig = true
269
				return m, tea.Quit
270
			}
271
		}
272
	}
273
	return m, nil
274
}
275
276
func (m Model) updateSelectModelFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
277
	switch msg := msg.(type) {
278
	case tea.KeyMsg:
279
		switch msg.String() {
280
		case "ctrl+c":
281
			m.quitting = true
282
			return m, tea.Quit
283
		case "esc":
284
			m.pickerFilterActive = false
285
			return m, nil
286
		case "up":
287
			if m.pickerFilterCursor > 0 {
288
				m.pickerFilterCursor--
289
			}
290
			return m, nil
291
		case "down":
292
			if m.pickerFilterCursor < len(m.pickerFilteredIdx)-1 {
293
				m.pickerFilterCursor++
294
			}
295
			return m, nil
296
		case "enter":
297
			if len(m.pickerFilteredIdx) == 0 {
298
				return m, nil
299
			}
300
			actualIdx := m.pickerFilteredIdx[m.pickerFilterCursor]
301
			m.pickerFilterActive = false
302
			if !m.pickerFilterRight {
303
				// Selected a family/category.
304
				m.familyCursor = actualIdx
305
				m.modelCursor = 0
306
				m.popularDrilldown = false
307
				m.modelFocus = true
308
				return m, nil
309
			}
310
			if m.isPopularCategory() && !m.popularDrilldown {
311
				// Selected a popular provider.
312
				m.popularProviderCursor = actualIdx
313
				m.popularDrilldown = true
314
				m.modelCursor = 0
315
				return m, nil
316
			}
317
			// Selected a model.
318
			models := m.currentRightPaneModels()
319
			if actualIdx < len(models) {
320
				selected := models[actualIdx]
321
				pc := m.cfg.Providers[m.pickerProvider]
322
				pc.Model = selected.ID
323
				pc.ContextWindow = selected.ContextLength
324
				if m.pickerProvider == "vkproxy" {
325
					pc.SmallModel = selected.ID
326
					pc = m.applyVKProxyEnv(pc, selected.ID)
327
				}
328
				m.cfg.Providers[m.pickerProvider] = pc
329
				if m.cache != nil {
330
					switch m.pickerProvider {
331
					case "ollama", "ollama-cloud", "lmstudio", "openrouter", "nvidia", "mistral":
332
						_ = m.cache.AddUsedModelForProvider(m.pickerProvider, selected.ID, selected.DisplayName())
333
					}
334
				}
335
				m.phase = phaseDone
336
				m.launchAfterConfig = true
337
				return m, tea.Quit
338
			}
339
			return m, nil
340
		}
341
	}
342
	var cmd tea.Cmd
343
	m.filterInput, cmd = m.filterInput.Update(msg)
344
	m.recomputePickerFilter()
345
	return m, cmd
346
}
347
348
func (m Model) updatePickerInput(msg tea.Msg) (tea.Model, tea.Cmd) {
349
	if msg, ok := msg.(tea.KeyMsg); ok {
350
		switch msg.String() {
351
		case "ctrl+c":
352
			m.quitting = true
353
			return m, tea.Quit
354
		case "esc":
355
			m.pickerInputActive = false
356
			m.pickerInputErr = ""
357
			m.modelFocus = false
358
			return m, nil
359
		case "enter":
360
			id := strings.TrimSpace(m.pickerInput.Value())
361
			if id == "" {
362
				return m, nil
363
			}
364
			if len(m.allModelIDs) > 0 && !m.allModelIDs[id] {
365
				m.pickerInputErr = "Model not found"
366
				return m, nil
367
			}
368
			pc := m.cfg.Providers[m.pickerProvider]
369
			pc.Model = id
370
			m.cfg.Providers[m.pickerProvider] = pc
371
			if (m.pickerProvider == "openrouter" || m.pickerProvider == "nvidia") && m.cache != nil {
372
				_ = m.cache.AddUsedModelForProvider(m.pickerProvider, id, id)
373
			}
374
			m.phase = phaseDone
375
			m.launchAfterConfig = true
376
			return m, tea.Quit
377
		default:
378
			m.pickerInputErr = ""
379
		}
380
	}
381
	var cmd tea.Cmd
382
	m.pickerInput, cmd = m.pickerInput.Update(msg)
383
	return m, cmd
384
}
385
386
func (m Model) viewSelectModel() string {
387
	label := m.pickerLabel()
388
389
	var b strings.Builder
390
	b.WriteString(logoStyle.Render(logo))
391
	b.WriteString("\n\n")
392
	b.WriteString(promptStyle.Render(fmt.Sprintf(" Select %s model", label)))
393
	b.WriteString("\n\n")
394
395
	if m.pickerFilterActive {
396
		fmt.Fprintf(&b, " / %s\n\n", m.filterInput.View())
397
	}
398
399
	isPopular := m.isPopularCategory()
400
401
	// Determine current model for highlighting.
402
	pc := m.cfg.Providers[m.pickerProvider]
403
	currentModel := pc.Model
404
	if currentModel == "" {
405
		if reg, ok := provider.Registry[m.pickerProvider]; ok {
406
			currentModel = reg.Model
407
		}
408
	}
409
410
	// Compute dynamic column widths from content.
411
	// Left: widest family name + cursor prefix (2).
412
	leftContentWidth := 0
413
	for _, fam := range m.families {
414
		if len(fam) > leftContentWidth {
415
			leftContentWidth = len(fam)
416
		}
417
	}
418
	leftContentWidth += 2 // "▸ " prefix
419
420
	// Right: widest model display name + cursor (2) + bar (2).
421
	rightContentWidth := 0
422
	for _, fam := range m.families {
423
		for _, item := range m.familyModels[fam] {
424
			n := lipgloss.Width(item.DisplayName())
425
			if n > rightContentWidth {
426
				rightContentWidth = n
427
			}
428
		}
429
	}
430
	// Also account for popular provider names and their models.
431
	for _, prov := range m.popularProviders {
432
		if len(prov)+2 > rightContentWidth {
433
			rightContentWidth = len(prov) + 2
434
		}
435
		for _, item := range m.popularProviderModels[prov] {
436
			n := lipgloss.Width(item.DisplayName())
437
			if n > rightContentWidth {
438
				rightContentWidth = n
439
			}
440
		}
441
	}
442
	rightContentWidth += 4 // "▸ " prefix + "▌ " bar
443
	if rightContentWidth < 44 {
444
		rightContentWidth = 44
445
	}
446
447
	// Width includes padding (1 each side) + border (1 each side).
448
	leftW := leftContentWidth + 4
449
	rightW := rightContentWidth + 4
450
451
	// Build left column (families / categories).
452
	filterLeft := m.pickerFilterActive && !m.pickerFilterRight
453
	var leftBuf strings.Builder
454
	for vi, i := range m.pickerVisibleFamilies() {
455
		fam := m.families[i]
456
457
		// Render separator lines as non-selectable dividers.
458
		if fam == ollama.SeparatorCategory {
459
			leftBuf.WriteString(hintStyle.Render("  " + fam))
460
			leftBuf.WriteString("\n")
461
			continue
462
		}
463
464
		var isCursor bool
465
		if filterLeft {
466
			isCursor = m.pickerFilterCursor == vi
467
		} else {
468
			isCursor = m.familyCursor == i && !m.modelFocus
469
		}
470
		active := m.familyCursor == i
471
472
		cur := "  "
473
		if isCursor {
474
			cur = cursorStyle.Render("▸ ")
475
		}
476
477
		name := fam
478
		if isCursor {
479
			name = cursorStyle.Render(name)
480
		} else if active {
481
			name = lipgloss.NewStyle().Bold(true).Render(name)
482
		}
483
484
		fmt.Fprintf(&leftBuf, "%s%s\n", cur, name)
485
	}
486
487
	// Build right column.
488
	var rightBuf strings.Builder
489
	if m.isEnterByHand() {
490
		// Show text input for manual model ID entry.
491
		if m.pickerInputActive {
492
			rightBuf.WriteString(" Enter model ID:\n\n")
493
			fmt.Fprintf(&rightBuf, " %s\n", m.pickerInput.View())
494
			if m.pickerInputErr != "" {
495
				rightBuf.WriteString("\n")
496
				rightBuf.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ %s", m.pickerInputErr)))
497
			}
498
			rightBuf.WriteString("\n")
499
			rightBuf.WriteString(hintStyle.Render(" enter confirm • esc back"))
500
		} else {
501
			rightBuf.WriteString(" Type a model ID manually\n\n")
502
			rightBuf.WriteString(hintStyle.Render(" press → or enter"))
503
		}
504
	} else if isPopular && m.hasPopularDrilldown() && !m.popularDrilldown {
505
		// Show provider list for Popular category.
506
		filterRight := m.pickerFilterActive && m.pickerFilterRight
507
		for vi, i := range m.pickerVisibleRightItems() {
508
			prov := m.popularProviders[i]
509
			var isCursor bool
510
			if filterRight {
511
				isCursor = m.pickerFilterCursor == vi
512
			} else {
513
				isCursor = m.popularProviderCursor == i && m.modelFocus
514
			}
515
			cur := "  "
516
			if isCursor {
517
				cur = cursorStyle.Render("▸ ")
518
			}
519
			name := prov
520
			if isCursor {
521
				name = cursorStyle.Render(name)
522
			}
523
			fmt.Fprintf(&rightBuf, "%s%s\n", cur, name)
524
		}
525
	} else {
526
		// Show models (regular categories or drilled-down Popular).
527
		if isPopular && m.popularDrilldown && m.popularProviderCursor < len(m.popularProviders) {
528
			rightBuf.WriteString(promptStyle.Render(m.popularProviders[m.popularProviderCursor]))
529
			rightBuf.WriteString("\n\n")
530
		}
531
532
		models := m.currentRightPaneModels()
533
		filterRight := m.pickerFilterActive && m.pickerFilterRight
534
		visibleIndices := m.pickerVisibleRightItems()
535
		total := len(visibleIndices)
536
537
		// Determine the active cursor position within the visible list.
538
		activeCursor := m.modelCursor
539
		if filterRight {
540
			activeCursor = m.pickerFilterCursor
541
		}
542
543
		// Compute visible window for scrolling.
544
		start := 0
545
		if total > maxVisibleModels {
546
			start = max(activeCursor-maxVisibleModels/2, 0)
547
			if start+maxVisibleModels > total {
548
				start = total - maxVisibleModels
549
			}
550
		}
551
		end := min(start+maxVisibleModels, total)
552
553
		if start > 0 {
554
			rightBuf.WriteString(hintStyle.Render(fmt.Sprintf("  ↑ %d more", start)))
555
			rightBuf.WriteString("\n")
556
		}
557
558
		for vi := start; vi < end; vi++ {
559
			realIdx := visibleIndices[vi]
560
			item := models[realIdx]
561
			var isCursor bool
562
			if filterRight {
563
				isCursor = m.pickerFilterCursor == vi
564
			} else {
565
				isCursor = m.modelCursor == realIdx && m.modelFocus
566
			}
567
			isActive := item.ID == currentModel
568
569
			cur := "  "
570
			if isCursor {
571
				cur = cursorStyle.Render("▸ ")
572
			}
573
574
			bar := "  "
575
			if isActive {
576
				bar = greenBar.Render("▌ ")
577
			}
578
579
			name := item.DisplayName()
580
			if isCursor {
581
				name = cursorStyle.Render(name)
582
			}
583
584
			fmt.Fprintf(&rightBuf, "%s%s%s\n", cur, bar, name)
585
		}
586
587
		if end < total {
588
			rightBuf.WriteString(hintStyle.Render(fmt.Sprintf("  ↓ %d more", total-end)))
589
			rightBuf.WriteString("\n")
590
		}
591
	}
592
593
	borderColor := lipgloss.Color("241")
594
	activeBorderColor := lipgloss.Color("170")
595
596
	leftBorder := lipgloss.RoundedBorder()
597
	leftBorderColor := borderColor
598
	if !m.modelFocus {
599
		leftBorderColor = activeBorderColor
600
	}
601
	leftPanel := lipgloss.NewStyle().
602
		Border(leftBorder).
603
		BorderForeground(leftBorderColor).
604
		Padding(0, 1).
605
		Width(leftW).
606
		Render(strings.TrimRight(leftBuf.String(), "\n"))
607
608
	rightBorder := lipgloss.RoundedBorder()
609
	rightBorderColor := borderColor
610
	if m.modelFocus {
611
		rightBorderColor = activeBorderColor
612
	}
613
	rightPanel := lipgloss.NewStyle().
614
		Border(rightBorder).
615
		BorderForeground(rightBorderColor).
616
		Padding(0, 1).
617
		Width(rightW).
618
		Render(strings.TrimRight(rightBuf.String(), "\n"))
619
620
	b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftPanel, " ", rightPanel))
621
622
	b.WriteString("\n")
623
	hint := " ←/→ switch column • ↑/↓ navigate • enter select • / filter • esc back"
624
	if m.pickerProvider == "vkproxy" {
625
		hint += " • ctrl+r refresh"
626
	}
627
	if m.pickerFilterActive {
628
		hint = " ↑/↓ navigate • enter select • esc clear filter"
629
	} else if m.pickerInputActive {
630
		hint = " enter confirm • esc back"
631
	} else if isPopular && m.hasPopularDrilldown() && m.modelFocus && !m.popularDrilldown {
632
		hint = " →/enter select provider • ↑/↓ navigate • / filter • ← back"
633
	} else if isPopular && m.hasPopularDrilldown() && m.modelFocus && m.popularDrilldown {
634
		hint = " enter select model • ↑/↓ navigate • / filter • ←/esc providers"
635
	}
636
	b.WriteString(hintStyle.Render(hint))
637
	if m.statusMsg != "" {
638
		b.WriteString("\n")
639
		b.WriteString(greenBar.Render(" " + m.statusMsg))
640
	}
641
642
	content := b.String()
643
	if m.width > 0 && m.height > 0 {
644
		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
645
	}
646
	return content
647
}
648

Source Files