provider_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
)
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 API variants, show model picker via live fetch.
291
		// Kimi Code (key "kimi") has no listable models endpoint on its
292
		// Anthropic gateway, so it falls through to the static model picker.
293
		if selected == "kimi-api-cn" || selected == "kimi-api-intl" {
294
			reg := provider.Registry[selected]
295
			return m.startFetch(kimiFetcher{apiKey: pc.APIKey, baseURL: reg.BaseURL, provKey: selected})
296
		}
297
		// For deepseek, show model picker.
298
		if selected == "deepseek" {
299
			return m.startFetch(deepseekFetcher{apiKey: pc.APIKey})
300
		}
301
		// For mistral, show model picker.
302
		if selected == "mistral" {
303
			return m.startFetch(mistralFetcher{apiKey: pc.APIKey})
304
		}
305
		// For providers with plans, show plan picker (skip for zai-coding, already set).
306
		if selected != "zai-coding" {
307
			if reg, ok := provider.Registry[selected]; ok && len(reg.Plans) > 0 {
308
				return m.showPlanPicker(reg.Plans), nil
309
			}
310
		}
311
		// For providers with static model lists, show model picker.
312
		if models, ok := provider.StaticModels[selected]; ok {
313
			return m.showStaticModelPicker(models), nil
314
		}
315
		m.phase = phaseDone
316
		m.launchAfterConfig = true
317
		return m, tea.Quit
318
	}
319
	// Copilot uses OAuth device flow; just activate and let main handle auth.
320
	if selected == "copilot" {
321
		m.cfg.ActiveProvider = selected
322
		m.phase = phaseDone
323
		m.launchAfterConfig = true
324
		return m, tea.Quit
325
	}
326
	// NoAuth providers (e.g. local Ollama) don't need an API key.
327
	if reg, ok := provider.Registry[selected]; ok && reg.NoAuth {
328
		m.cfg.ActiveProvider = selected
329
		// For ollama, show model picker.
330
		if selected == "ollama" {
331
			return m.startFetch(ollamaFetcher{baseURL: reg.BaseURL, provKey: selected})
332
		}
333
		// For lmstudio, show model picker.
334
		if selected == "lmstudio" {
335
			return m.startFetch(lmstudioFetcher{baseURL: reg.BaseURL})
336
		}
337
		m.phase = phaseDone
338
		m.launchAfterConfig = true
339
		return m, tea.Quit
340
	}
341
	// Otherwise prompt for key.
342
	m.input.SetValue("")
343
	m.input.Focus()
344
	m.phase = phaseInputKey
345
	return m, m.input.Cursor.BlinkCmd()
346
}
347
348
// selectProviderDefault activates the selected provider with registry defaults,
349
// skipping model/plan pickers. Still prompts for API key if not set.
350
func (m Model) selectProviderDefault() (tea.Model, tea.Cmd) {
351
	selected := m.selected
352
	// For zai-coding, check zai's key.
353
	checkKey := selected
354
	if selected == "zai-coding" {
355
		checkKey = "zai"
356
	}
357
	// If provider already has a key, just activate it.
358
	if pc, ok := m.cfg.Providers[checkKey]; ok && SanitizeAPIKey(pc.APIKey) != "" {
359
		pc.APIKey = SanitizeAPIKey(pc.APIKey)
360
		m.cfg.Providers[checkKey] = pc
361
		if selected == "zai-coding" {
362
			pc.Plan = "coding"
363
			m.cfg.Providers["zai"] = pc
364
			m.cfg.ActiveProvider = "zai"
365
			m.selected = "zai"
366
		} else {
367
			m.cfg.ActiveProvider = selected
368
		}
369
		m.phase = phaseDone
370
		m.launchAfterConfig = true
371
		return m, tea.Quit
372
	}
373
	// Copilot uses OAuth device flow.
374
	if selected == "copilot" {
375
		m.cfg.ActiveProvider = selected
376
		m.phase = phaseDone
377
		m.launchAfterConfig = true
378
		return m, tea.Quit
379
	}
380
	// NoAuth providers don't need an API key.
381
	if _, ok := provider.Registry[selected]; ok && provider.Registry[selected].NoAuth {
382
		m.cfg.ActiveProvider = selected
383
		m.phase = phaseDone
384
		m.launchAfterConfig = true
385
		return m, tea.Quit
386
	}
387
	// Otherwise prompt for key (can't skip this).
388
	m.input.SetValue("")
389
	m.input.Focus()
390
	m.phase = phaseInputKey
391
	return m, m.input.Cursor.BlinkCmd()
392
}
393
394
// ollamaToggleStyle renders the local/cloud toggle for ollama entries.
395
func (m Model) ollamaToggleStyle(isCursor bool) string {
396
	localStyle := descStyle
397
	cloudStyle := descStyle
398
	if !m.ollamaCloud {
399
		if isCursor {
400
			localStyle = cursorStyle
401
		} else {
402
			localStyle = nameStyle
403
		}
404
	} else {
405
		if isCursor {
406
			cloudStyle = cursorStyle
407
		} else {
408
			cloudStyle = nameStyle
409
		}
410
	}
411
	return fmt.Sprintf(" [%s|%s]", localStyle.Render("local"), cloudStyle.Render("cloud"))
412
}
413
414
// zaiToggleStyle renders the api/coding toggle for zai entries.
415
func (m Model) zaiToggleStyle(isCursor bool) string {
416
	apiStyle := descStyle
417
	codingStyle := descStyle
418
	if !m.zaiCoding {
419
		if isCursor {
420
			apiStyle = cursorStyle
421
		} else {
422
			apiStyle = nameStyle
423
		}
424
	} else {
425
		if isCursor {
426
			codingStyle = cursorStyle
427
		} else {
428
			codingStyle = nameStyle
429
		}
430
	}
431
	return fmt.Sprintf(" [%s|%s]", apiStyle.Render("api"), codingStyle.Render("coding"))
432
}
433
434
// kimiToggleStyle renders the variant toggle for kimi entries.
435
func (m Model) kimiToggleStyle(isCursor bool) string {
436
	codeStyle := descStyle
437
	apiCNStyle := descStyle
438
	apiIntlStyle := descStyle
439
440
	switch m.kimiVariant {
441
	case 0:
442
		if isCursor {
443
			codeStyle = cursorStyle
444
		} else {
445
			codeStyle = nameStyle
446
		}
447
	case 1:
448
		if isCursor {
449
			apiCNStyle = cursorStyle
450
		} else {
451
			apiCNStyle = nameStyle
452
		}
453
	default:
454
		if isCursor {
455
			apiIntlStyle = cursorStyle
456
		} else {
457
			apiIntlStyle = nameStyle
458
		}
459
	}
460
	return fmt.Sprintf(" [%s|%s|%s]", codeStyle.Render("code"), apiCNStyle.Render("cn"), apiIntlStyle.Render("int"))
461
}
462
463
func (m Model) viewSelect() string {
464
	var b strings.Builder
465
466
	b.WriteString(logoStyle.Render(logo))
467
	b.WriteString("\n\n")
468
469
	if m.filterActive {
470
		fmt.Fprintf(&b, " / %s\n\n", m.filterInput.View())
471
	}
472
473
	// Determine which entries to display.
474
	type indexedEntry struct {
475
		idx   int
476
		entry entry
477
	}
478
	var visible []indexedEntry
479
	if m.filterActive {
480
		for _, idx := range m.filteredIdx {
481
			visible = append(visible, indexedEntry{idx: idx, entry: m.entries[idx]})
482
		}
483
	} else {
484
		for i, e := range m.entries {
485
			visible = append(visible, indexedEntry{idx: i, entry: e})
486
		}
487
	}
488
489
	for vi, ie := range visible {
490
		e := ie.entry
491
		keys := []string{e.Key}
492
		if e.AltKey != "" {
493
			keys = append(keys, e.AltKey)
494
		}
495
		if len(e.AltKeys) > 0 {
496
			keys = append(keys, e.AltKeys...)
497
		}
498
499
		// Check if any of the keys is active.
500
		isActive := false
501
		hasKey := false
502
		isHidden := false
503
		for _, key := range keys {
504
			if key == m.cfg.ActiveProvider {
505
				isActive = true
506
			}
507
			if pc, ok := m.cfg.Providers[key]; ok {
508
				if SanitizeAPIKey(pc.APIKey) != "" {
509
					hasKey = true
510
				}
511
				if pc.Hidden {
512
					isHidden = true
513
				}
514
			}
515
		}
516
517
		var isCursor bool
518
		if m.filterActive {
519
			isCursor = m.cursor == vi
520
		} else {
521
			isCursor = m.cursor == ie.idx
522
		}
523
524
		// Cursor indicator.
525
		cur := "  "
526
		if isCursor {
527
			cur = cursorStyle.Render("▸ ")
528
		}
529
530
		// Status bar: green = active, blue = has key, space = unconfigured.
531
		bar := "  "
532
		if isActive {
533
			bar = greenBar.Render("▌ ")
534
		} else if hasKey {
535
			bar = blueBar.Render("▌ ")
536
		}
537
538
		// Name - for ollama, zai, and kimi show generic name with toggle.
539
		name := e.Name
540
		if e.Key == "ollama" && e.AltKey != "" {
541
			// Use generic "Ollama" name with toggle indicator.
542
			name = "Ollama" + m.ollamaToggleStyle(isCursor)
543
		} else if e.Key == "zai" && e.AltKey != "" {
544
			// Use generic "z.AI" name with toggle indicator.
545
			name = "z.AI" + m.zaiToggleStyle(isCursor)
546
		} else if e.Key == "kimi" && len(e.AltKeys) == 2 {
547
			// Use generic "Kimi" name with toggle indicator.
548
			name = "Kimi" + m.kimiToggleStyle(isCursor)
549
		} else if isHidden {
550
			name = descStyle.Render(name + " (hidden)")
551
		} else if isCursor {
552
			name = cursorStyle.Render(name)
553
		}
554
555
		// Description indented under the name.
556
		desc := descStyle.Render("      " + e.Description)
557
558
		fmt.Fprintf(&b, " %s%s%s\n%s\n", cur, bar, name, desc)
559
	}
560
561
	b.WriteString("\n")
562
	if m.statusMsg != "" {
563
		b.WriteString(greenBar.Render(" " + m.statusMsg))
564
		b.WriteString("\n\n")
565
	}
566
	if m.modelsErr != nil {
567
		b.WriteString(errStyle.Render(fmt.Sprintf(" ⚠ Could not fetch models: %v", m.modelsErr)))
568
		b.WriteString("\n\n")
569
	}
570
	if m.filterActive {
571
		b.WriteString(hintStyle.Render(" ↑/↓ navigate • enter select • esc clear filter"))
572
	} else {
573
		hideHint := "x hide"
574
		if m.cursor < len(m.entries) {
575
			keys := m.selectedEntryKeys()
576
			hidden := false
577
			for _, key := range keys {
578
				if pc, ok := m.cfg.Providers[key]; ok && pc.Hidden {
579
					hidden = true
580
					break
581
				}
582
			}
583
			if hidden {
584
				hideHint = "x unhide"
585
			}
586
		}
587
		showAllHint := "H show hidden"
588
		if m.showHidden {
589
			showAllHint = "H hide hidden"
590
		}
591
		// Add left/right hint for toggleable entries (ollama, zai, kimi).
592
		toggleHint := ""
593
		if m.cursor < len(m.entries) && (m.entries[m.cursor].AltKey != "" || len(m.entries[m.cursor].AltKeys) > 0) {
594
			toggleHint = " • ←/→ toggle"
595
		}
596
		b.WriteString(hintStyle.Render(fmt.Sprintf(" ↑/↓ navigate • enter select • S use defaults • e edit key%s", toggleHint)))
597
		b.WriteString("\n")
598
		b.WriteString(hintStyle.Render(fmt.Sprintf(" d/D clear cache • %s • %s • / filter • q quit", hideHint, showAllHint)))
599
	}
600
601
	content := b.String()
602
	if m.width > 0 && m.height > 0 {
603
		return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, content)
604
	}
605
	return content
606
}
607

Source Files