provider_select.go

v0.6.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
)
11
12
func (m Model) updateSelect(msg tea.Msg) (tea.Model, tea.Cmd) {
13
	if m.filterActive {
14
		return m.updateSelectFilter(msg)
15
	}
16
	switch msg := msg.(type) {
17
	case tea.KeyMsg:
18
		m.modelsErr = nil
19
		m.statusMsg = ""
20
		switch msg.String() {
21
		case "ctrl+c", "q":
22
			m.quitting = true
23
			return m, tea.Quit
24
		case "up", "k":
25
			if m.cursor > 0 {
26
				m.cursor--
27
			}
28
		case "down", "j":
29
			if m.cursor < len(m.entries)-1 {
30
				m.cursor++
31
			}
32
		case "left", "h":
33
			// Toggle ollama to local mode if on the ollama entry and currently in cloud mode.
34
			if m.cursor < len(m.entries) {
35
				if m.entries[m.cursor].Key == "ollama" && m.entries[m.cursor].AltKey != "" && m.ollamaCloud {
36
					m.ollamaCloud = false
37
				}
38
				// Toggle zai to API mode if on the zai entry and currently in coding mode.
39
				if m.entries[m.cursor].Key == "zai" && m.entries[m.cursor].AltKey != "" && m.zaiCoding {
40
					m.zaiCoding = false
41
				}
42
				// Cycle kimi variant: code -> api-intl -> api-cn -> code
43
				if m.entries[m.cursor].Key == "kimi" && len(m.entries[m.cursor].AltKeys) == 2 {
44
					m.kimiVariant--
45
					if m.kimiVariant < 0 {
46
						m.kimiVariant = 2
47
					}
48
				}
49
			}
50
		case "right", "l":
51
			// Toggle ollama to cloud mode if on the ollama entry and currently in local mode.
52
			if m.cursor < len(m.entries) {
53
				if m.entries[m.cursor].Key == "ollama" && m.entries[m.cursor].AltKey != "" && !m.ollamaCloud {
54
					m.ollamaCloud = true
55
				}
56
				// Toggle zai to Coding mode if on the zai entry and currently in API mode.
57
				if m.entries[m.cursor].Key == "zai" && m.entries[m.cursor].AltKey != "" && !m.zaiCoding {
58
					m.zaiCoding = true
59
				}
60
				// Cycle kimi variant: code -> api-cn -> api-intl -> code
61
				if m.entries[m.cursor].Key == "kimi" && len(m.entries[m.cursor].AltKeys) == 2 {
62
					m.kimiVariant++
63
					if m.kimiVariant > 2 {
64
						m.kimiVariant = 0
65
					}
66
				}
67
			}
68
		case "/":
69
			m.filterActive = true
70
			m.filterInput.SetValue("")
71
			m.filterInput.Focus()
72
			m.cursor = 0
73
			m.recomputeFilter()
74
			return m, m.filterInput.Cursor.BlinkCmd()
75
		case "S":
76
			// Use default configuration — skip model picker.
77
			m.selected = m.selectedEntryKey()
78
			return m.selectProviderDefault()
79
		case "enter":
80
			m.selected = m.selectedEntryKey()
81
			return m.selectProvider()
82
		case "x":
83
			// Toggle hidden state for current provider(s).
84
			keys := m.selectedEntryKeys()
85
			for _, key := range keys {
86
				pc := m.cfg.Providers[key]
87
				pc.Hidden = !pc.Hidden
88
				m.cfg.Providers[key] = pc
89
			}
90
			if !m.showHidden {
91
				anyHidden := false
92
				for _, key := range keys {
93
					if m.cfg.Providers[key].Hidden {
94
						anyHidden = true
95
						break
96
					}
97
				}
98
				if anyHidden {
99
					m.entries = buildEntries(m.cfg, m.showHidden)
100
					if m.cursor >= len(m.entries) {
101
						m.cursor = max(0, len(m.entries)-1)
102
					}
103
				}
104
			}
105
			return m, nil
106
		case "H":
107
			// Toggle showing hidden providers.
108
			m.showHidden = !m.showHidden
109
			prev := ""
110
			if m.cursor < len(m.entries) {
111
				prev = m.entries[m.cursor].Key
112
			}
113
			m.entries = buildEntries(m.cfg, m.showHidden)
114
			m.cursor = 0
115
			for i, e := range m.entries {
116
				if e.Key == prev {
117
					m.cursor = i
118
					break
119
				}
120
			}
121
			return m, nil
122
		case "d":
123
			// Drop cache for current provider.
124
			if m.cache != nil {
125
				keys := m.selectedEntryKeys()
126
				for _, key := range keys {
127
					_ = m.cache.DropProviderCache(key)
128
					// ollama-cloud has an extra cache key for popular models.
129
					if key == "ollama-cloud" {
130
						_ = m.cache.DropProviderCache("ollama-cloud-popular")
131
					}
132
				}
133
				if len(keys) == 1 {
134
					m.statusMsg = fmt.Sprintf("Cache cleared for %s", keys[0])
135
				} else {
136
					m.statusMsg = fmt.Sprintf("Cache cleared for %s", strings.Join(keys, ", "))
137
				}
138
			}
139
			return m, nil
140
		case "D":
141
			// Drop all caches.
142
			if m.cache != nil {
143
				_ = m.cache.DropAllCaches()
144
				m.statusMsg = "All caches cleared"
145
			}
146
			return m, nil
147
		case "e":
148
			// Edit key for current provider.
149
			m.selected = m.selectedEntryKey()
150
			existing := ""
151
			if pc, ok := m.cfg.Providers[m.selected]; ok {
152
				// Sanitize the existing key in case it was corrupted
153
				existing = SanitizeAPIKey(pc.APIKey)
154
			}
155
			m.input.SetValue(existing)
156
			m.input.Focus()
157
			m.phase = phaseInputKey
158
			return m, m.input.Cursor.BlinkCmd()
159
		}
160
	}
161
	return m, nil
162
}
163
164
func (m Model) updateSelectFilter(msg tea.Msg) (tea.Model, tea.Cmd) {
165
	switch msg := msg.(type) {
166
	case tea.KeyMsg:
167
		switch msg.String() {
168
		case "ctrl+c":
169
			m.quitting = true
170
			return m, tea.Quit
171
		case "esc":
172
			m.filterActive = false
173
			// Restore cursor to the full list position.
174
			if len(m.filteredIdx) > 0 && m.cursor < len(m.filteredIdx) {
175
				m.cursor = m.filteredIdx[m.cursor]
176
			} else {
177
				m.cursor = 0
178
			}
179
			return m, nil
180
		case "up":
181
			if m.cursor > 0 {
182
				m.cursor--
183
			}
184
			return m, nil
185
		case "down":
186
			if m.cursor < len(m.filteredIdx)-1 {
187
				m.cursor++
188
			}
189
			return m, nil
190
		case "enter":
191
			if len(m.filteredIdx) == 0 {
192
				return m, nil
193
			}
194
			m.selected = m.selectedEntryKey()
195
			m.filterActive = false
196
			return m.selectProvider()
197
		}
198
	}
199
	var cmd tea.Cmd
200
	m.filterInput, cmd = m.filterInput.Update(msg)
201
	m.recomputeFilter()
202
	return m, cmd
203
}
204
205
// selectedEntryKey returns the effective key for the currently selected entry,
206
// accounting for ollama cloud/local toggle, zai api/coding toggle, and kimi variant.
207
func (m Model) selectedEntryKey() string {
208
	if m.cursor >= len(m.entries) {
209
		return ""
210
	}
211
	e := m.entries[m.cursor]
212
	if e.Key == "ollama" && e.AltKey != "" && m.ollamaCloud {
213
		return e.AltKey // "ollama-cloud"
214
	}
215
	if e.Key == "zai" && e.AltKey != "" && m.zaiCoding {
216
		return e.AltKey // "zai-coding"
217
	}
218
	if e.Key == "kimi" && len(e.AltKeys) == 2 {
219
		switch m.kimiVariant {
220
		case 1:
221
			return e.AltKeys[0] // "kimi-api-cn"
222
		case 2:
223
			return e.AltKeys[1] // "kimi-api-intl"
224
		}
225
	}
226
	return e.Key
227
}
228
229
// selectedEntryKeys returns all provider keys associated with the current entry
230
// (for ollama, zai, kimi this includes all variants).
231
func (m Model) selectedEntryKeys() []string {
232
	if m.cursor >= len(m.entries) {
233
		return nil
234
	}
235
	e := m.entries[m.cursor]
236
	if len(e.AltKeys) == 2 {
237
		// 3-variant entry (kimi)
238
		return []string{e.Key, e.AltKeys[0], e.AltKeys[1]}
239
	}
240
	if e.AltKey != "" {
241
		// 2-variant entry (ollama, zai)
242
		return []string{e.Key, e.AltKey}
243
	}
244
	return []string{e.Key}
245
}
246
247
func (m Model) selectProvider() (tea.Model, tea.Cmd) {
248
	selected := m.selected
249
	// VK LLM Proxy: always parse zip and show picker.
250
	if selected == "vkproxy" {
251
		return m.startVKProxyPicker()
252
	}
253
	// For zai-coding, check zai's key (migration moved zai-coding -> zai with plan=coding).
254
	checkKey := selected
255
	if selected == "zai-coding" {
256
		checkKey = "zai"
257
	}
258
	// If provider already has a key, just activate it.
259
	// Use sanitized key to handle corrupted config values.
260
	if pc, ok := m.cfg.Providers[checkKey]; ok && SanitizeAPIKey(pc.APIKey) != "" {
261
		// Fix corrupted key in memory if needed
262
		pc.APIKey = SanitizeAPIKey(pc.APIKey)
263
		m.cfg.Providers[checkKey] = pc
264
		// For zai-coding, set plan and use "zai" as the provider key.
265
		if selected == "zai-coding" {
266
			pc.Plan = "coding"
267
			m.cfg.Providers["zai"] = pc
268
			m.cfg.ActiveProvider = "zai"
269
			m.selected = "zai"
270
		} else {
271
			m.cfg.ActiveProvider = selected
272
		}
273
		// For copilot, show model picker.
274
		if selected == "copilot" {
275
			return m.startFetch(copilotFetcher{oauthToken: pc.APIKey})
276
		}
277
		// For openrouter, show model picker.
278
		if selected == "openrouter" {
279
			return m.startFetch(openrouterFetcher{apiKey: pc.APIKey})
280
		}
281
		// For nvidia, show model picker.
282
		if selected == "nvidia" {
283
			return m.startFetch(nvidiaFetcher{apiKey: pc.APIKey})
284
		}
285
		// For ollama or ollama-cloud, show model picker.
286
		if selected == "ollama" || selected == "ollama-cloud" {
287
			reg := provider.Registry[selected]
288
			return m.startFetch(ollamaFetcher{apiKey: pc.APIKey, baseURL: reg.BaseURL, provKey: selected})
289
		}
290
		// For kimi variants, show model picker.
291
		if selected == "kimi" || selected == "kimi-api-cn" || selected == "kimi-api-intl" {
292
			reg := provider.Registry[selected]
293
			return m.startFetch(kimiFetcher{apiKey: pc.APIKey, baseURL: reg.BaseURL, provKey: selected})
294
		}
295
		// For deepseek, show model picker.
296
		if selected == "deepseek" {
297
			return m.startFetch(deepseekFetcher{apiKey: pc.APIKey})
298
		}
299
		// For mistral, show model picker.
300
		if selected == "mistral" {
301
			return m.startFetch(mistralFetcher{apiKey: pc.APIKey})
302
		}
303
		// For providers with plans, show plan picker (skip for zai-coding, already set).
304
		if selected != "zai-coding" {
305
			if reg, ok := provider.Registry[selected]; ok && len(reg.Plans) > 0 {
306
				return m.showPlanPicker(reg.Plans), nil
307
			}
308
		}
309
		// For providers with static model lists, show model picker.
310
		if models, ok := provider.StaticModels[m.selected]; ok {
311
			return m.showStaticModelPicker(models), nil
312
		}
313
		m.phase = phaseDone
314
		m.launchAfterConfig = true
315
		return m, tea.Quit
316
	}
317
	// Copilot uses OAuth device flow; just activate and let main handle auth.
318
	if selected == "copilot" {
319
		m.cfg.ActiveProvider = selected
320
		m.phase = phaseDone
321
		m.launchAfterConfig = true
322
		return m, tea.Quit
323
	}
324
	// NoAuth providers (e.g. local Ollama) don't need an API key.
325
	if reg, ok := provider.Registry[selected]; ok && reg.NoAuth {
326
		m.cfg.ActiveProvider = selected
327
		// For ollama, show model picker.
328
		if selected == "ollama" {
329
			return m.startFetch(ollamaFetcher{baseURL: reg.BaseURL, provKey: selected})
330
		}
331
		// For lmstudio, show model picker.
332
		if selected == "lmstudio" {
333
			return m.startFetch(lmstudioFetcher{baseURL: reg.BaseURL})
334
		}
335
		m.phase = phaseDone
336
		m.launchAfterConfig = true
337
		return m, tea.Quit
338
	}
339
	// Otherwise prompt for key.
340
	m.input.SetValue("")
341
	m.input.Focus()
342
	m.phase = phaseInputKey
343
	return m, m.input.Cursor.BlinkCmd()
344
}
345
346
// selectProviderDefault activates the selected provider with registry defaults,
347
// skipping model/plan pickers. Still prompts for API key if not set.
348
func (m Model) selectProviderDefault() (tea.Model, tea.Cmd) {
349
	selected := m.selected
350
	// For zai-coding, check zai's key.
351
	checkKey := selected
352
	if selected == "zai-coding" {
353
		checkKey = "zai"
354
	}
355
	// If provider already has a key, just activate it.
356
	if pc, ok := m.cfg.Providers[checkKey]; ok && SanitizeAPIKey(pc.APIKey) != "" {
357
		pc.APIKey = SanitizeAPIKey(pc.APIKey)
358
		m.cfg.Providers[checkKey] = pc
359
		if selected == "zai-coding" {
360
			pc.Plan = "coding"
361
			m.cfg.Providers["zai"] = pc
362
			m.cfg.ActiveProvider = "zai"
363
			m.selected = "zai"
364
		} else {
365
			m.cfg.ActiveProvider = selected
366
		}
367
		m.phase = phaseDone
368
		m.launchAfterConfig = true
369
		return m, tea.Quit
370
	}
371
	// Copilot uses OAuth device flow.
372
	if selected == "copilot" {
373
		m.cfg.ActiveProvider = selected
374
		m.phase = phaseDone
375
		m.launchAfterConfig = true
376
		return m, tea.Quit
377
	}
378
	// NoAuth providers don't need an API key.
379
	if _, ok := provider.Registry[selected]; ok && provider.Registry[selected].NoAuth {
380
		m.cfg.ActiveProvider = selected
381
		m.phase = phaseDone
382
		m.launchAfterConfig = true
383
		return m, tea.Quit
384
	}
385
	// Otherwise prompt for key (can't skip this).
386
	m.input.SetValue("")
387
	m.input.Focus()
388
	m.phase = phaseInputKey
389
	return m, m.input.Cursor.BlinkCmd()
390
}
391
392
// ollamaToggleStyle renders the local/cloud toggle for ollama entries.
393
func (m Model) ollamaToggleStyle(isCursor bool) string {
394
	localStyle := descStyle
395
	cloudStyle := descStyle
396
	if !m.ollamaCloud {
397
		if isCursor {
398
			localStyle = cursorStyle
399
		} else {
400
			localStyle = nameStyle
401
		}
402
	} else {
403
		if isCursor {
404
			cloudStyle = cursorStyle
405
		} else {
406
			cloudStyle = nameStyle
407
		}
408
	}
409
	return fmt.Sprintf(" [%s|%s]", localStyle.Render("local"), cloudStyle.Render("cloud"))
410
}
411
412
// zaiToggleStyle renders the api/coding toggle for zai entries.
413
func (m Model) zaiToggleStyle(isCursor bool) string {
414
	apiStyle := descStyle
415
	codingStyle := descStyle
416
	if !m.zaiCoding {
417
		if isCursor {
418
			apiStyle = cursorStyle
419
		} else {
420
			apiStyle = nameStyle
421
		}
422
	} else {
423
		if isCursor {
424
			codingStyle = cursorStyle
425
		} else {
426
			codingStyle = nameStyle
427
		}
428
	}
429
	return fmt.Sprintf(" [%s|%s]", apiStyle.Render("api"), codingStyle.Render("coding"))
430
}
431
432
// kimiToggleStyle renders the variant toggle for kimi entries.
433
func (m Model) kimiToggleStyle(isCursor bool) string {
434
	codeStyle := descStyle
435
	apiCNStyle := descStyle
436
	apiIntlStyle := descStyle
437
438
	if m.kimiVariant == 0 {
439
		if isCursor {
440
			codeStyle = cursorStyle
441
		} else {
442
			codeStyle = nameStyle
443
		}
444
	} else if m.kimiVariant == 1 {
445
		if isCursor {
446
			apiCNStyle = cursorStyle
447
		} else {
448
			apiCNStyle = nameStyle
449
		}
450
	} else {
451
		if isCursor {
452
			apiIntlStyle = cursorStyle
453
		} else {
454
			apiIntlStyle = nameStyle
455
		}
456
	}
457
	return fmt.Sprintf(" [%s|%s|%s]", codeStyle.Render("code"), apiCNStyle.Render("cn"), apiIntlStyle.Render("int"))
458
}
459
460
func (m Model) viewSelect() string {
461
	var b strings.Builder
462
463
	b.WriteString(logoStyle.Render(logo))
464
	b.WriteString("\n\n")
465
466
	if m.filterActive {
467
		b.WriteString(fmt.Sprintf(" / %s\n\n", m.filterInput.View()))
468
	}
469
470
	// Determine which entries to display.
471
	type indexedEntry struct {
472
		idx   int
473
		entry entry
474
	}
475
	var visible []indexedEntry
476
	if m.filterActive {
477
		for _, idx := range m.filteredIdx {
478
			visible = append(visible, indexedEntry{idx: idx, entry: m.entries[idx]})
479
		}
480
	} else {
481
		for i, e := range m.entries {
482
			visible = append(visible, indexedEntry{idx: i, entry: e})
483
		}
484
	}
485
486
	for vi, ie := range visible {
487
		e := ie.entry
488
		keys := []string{e.Key}
489
		if e.AltKey != "" {
490
			keys = append(keys, e.AltKey)
491
		}
492
		if len(e.AltKeys) > 0 {
493
			keys = append(keys, e.AltKeys...)
494
		}
495
496
		// Check if any of the keys is active.
497
		isActive := false
498
		hasKey := false
499
		isHidden := false
500
		for _, key := range keys {
501
			if key == m.cfg.ActiveProvider {
502
				isActive = true
503
			}
504
			if pc, ok := m.cfg.Providers[key]; ok {
505
				if SanitizeAPIKey(pc.APIKey) != "" {
506
					hasKey = true
507
				}
508
				if pc.Hidden {
509
					isHidden = true
510
				}
511
			}
512
		}
513
514
		var isCursor bool
515
		if m.filterActive {
516
			isCursor = m.cursor == vi
517
		} else {
518
			isCursor = m.cursor == ie.idx
519
		}
520
521
		// Cursor indicator.
522
		cur := "  "
523
		if isCursor {
524
			cur = cursorStyle.Render("▸ ")
525
		}
526
527
		// Status bar: green = active, blue = has key, space = unconfigured.
528
		bar := "  "
529
		if isActive {
530
			bar = greenBar.Render("▌ ")
531
		} else if hasKey {
532
			bar = blueBar.Render("▌ ")
533
		}
534
535
		// Name - for ollama, zai, and kimi show generic name with toggle.
536
		name := e.Name
537
		if e.Key == "ollama" && e.AltKey != "" {
538
			// Use generic "Ollama" name with toggle indicator.
539
			name = "Ollama" + m.ollamaToggleStyle(isCursor)
540
		} else if e.Key == "zai" && e.AltKey != "" {
541
			// Use generic "z.AI" name with toggle indicator.
542
			name = "z.AI" + m.zaiToggleStyle(isCursor)
543
		} else if e.Key == "kimi" && len(e.AltKeys) == 2 {
544
			// Use generic "Kimi" name with toggle indicator.
545
			name = "Kimi" + m.kimiToggleStyle(isCursor)
546
		} else if isHidden {
547
			name = descStyle.Render(name + " (hidden)")
548
		} else if isCursor {
549
			name = cursorStyle.Render(name)
550
		}
551
552
		// Description indented under the name.
553
		desc := descStyle.Render("      " + e.Description)
554
555
		b.WriteString(fmt.Sprintf(" %s%s%s\n%s\n", cur, bar, name, desc))
556
	}
557
558
	b.WriteString("\n")
559
	if m.statusMsg != "" {
560
		b.WriteString(greenBar.Render(" " + m.statusMsg))
561
		b.WriteString("\n\n")
562
	}
563
	if m.modelsErr != nil {
564
		b.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ Could not fetch models: %v", m.modelsErr)))
565
		b.WriteString("\n\n")
566
	}
567
	if m.filterActive {
568
		b.WriteString(hintStyle.Render(" ↑/↓ navigate • enter select • esc clear filter"))
569
	} else {
570
		hideHint := "x hide"
571
		if m.cursor < len(m.entries) {
572
			keys := m.selectedEntryKeys()
573
			hidden := false
574
			for _, key := range keys {
575
				if pc, ok := m.cfg.Providers[key]; ok && pc.Hidden {
576
					hidden = true
577
					break
578
				}
579
			}
580
			if hidden {
581
				hideHint = "x unhide"
582
			}
583
		}
584
		showAllHint := "H show hidden"
585
		if m.showHidden {
586
			showAllHint = "H hide hidden"
587
		}
588
		// Add left/right hint for toggleable entries (ollama, zai, kimi).
589
		toggleHint := ""
590
		if m.cursor < len(m.entries) && (m.entries[m.cursor].AltKey != "" || len(m.entries[m.cursor].AltKeys) > 0) {
591
			toggleHint = " • ←/→ toggle"
592
		}
593
		b.WriteString(hintStyle.Render(fmt.Sprintf(" ↑/↓ navigate • enter select • S use defaults • e edit key%s", toggleHint)))
594
		b.WriteString("\n")
595
		b.WriteString(hintStyle.Render(fmt.Sprintf(" d/D clear cache • %s • %s • / filter • q quit", hideHint, showAllHint)))
596
	}
597
598
	content := b.String()
599
	if m.width > 0 && m.height > 0 {
600
		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
601
	}
602
	return content
603
}
604

Source Files