@@ -1,8 +1,12 @@
1package models
1package models
4 "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/help"
5 "github.com/charmbracelet/bubbles/v2/key"
8 "github.com/charmbracelet/bubbles/v2/key"
9+ "github.com/charmbracelet/bubbles/v2/spinner"
6 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
7 "github.com/charmbracelet/crush/internal/config"
11 "github.com/charmbracelet/crush/internal/config"
8 "github.com/charmbracelet/crush/internal/fur/provider"
12 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -56,6 +60,14 @@ type modelDialogCmp struct {
56 modelList *ModelListComponent
60 modelList *ModelListComponent
57 keyMap KeyMap
61 keyMap KeyMap
58 help help.Model
62 help help.Model
66+ apiKeyInput *APIKeyInput
67+ selectedModel *ModelOption
68+ selectedModelType config.SelectedModelType
61func NewModelDialogCmp() ModelDialog {
73func NewModelDialogCmp() ModelDialog {
@@ -75,19 +87,22 @@ func NewModelDialogCmp() ModelDialog {
75 t := styles.CurrentTheme()
87 t := styles.CurrentTheme()
76 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
88 inputStyle := t.S().Base.Padding(0, 1, 0, 1)
77 modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
89 modelList := NewModelListComponent(listKeyMap, inputStyle, "Choose a model for large, complex tasks")
90+ apiKeyInput := NewAPIKeyInput()
91+ apiKeyInput.SetShowTitle(false)
78 help := help.New()
92 help := help.New()
79 help.Styles = t.S().Help
93 help.Styles = t.S().Help
81 return &modelDialogCmp{
95 return &modelDialogCmp{
82− modelList: modelList,
96+ modelList: modelList,
97+ apiKeyInput: apiKeyInput,
83− width: defaultWidth,
98+ width: defaultWidth,
84− keyMap: DefaultKeyMap(),
99+ keyMap: DefaultKeyMap(),
85− help: help,
100+ help: help,
89func (m *modelDialogCmp) Init() tea.Cmd {
104func (m *modelDialogCmp) Init() tea.Cmd {
90− return m.modelList.Init()
105+ return tea.Batch(m.modelList.Init(), m.apiKeyInput.Init())
93func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
108func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -95,10 +110,58 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
95 case tea.WindowSizeMsg:
110 case tea.WindowSizeMsg:
96 m.wWidth = msg.Width
111 m.wWidth = msg.Width
97 m.wHeight = msg.Height
112 m.wHeight = msg.Height
113+ m.apiKeyInput.SetWidth(m.width - 2)
114+ m.help.Width = m.width - 2
98 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
115 return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
116+ case APIKeyStateChangeMsg:
117+ u, cmd := m.apiKeyInput.Update(msg)
118+ m.apiKeyInput = u.(*APIKeyInput)
99 case tea.KeyPressMsg:
120 case tea.KeyPressMsg:
101 case key.Matches(msg, m.keyMap.Select):
122 case key.Matches(msg, m.keyMap.Select):
123+ if m.isAPIKeyValid {
124+ return m, m.saveAPIKeyAndContinue(m.apiKeyValue)
127+ // Handle API key submission
128+ m.apiKeyValue = m.apiKeyInput.Value()
129+ provider, err := m.getProvider(m.selectedModel.Provider.ID)
130+ if err != nil || provider == nil {
131+ return m, util.ReportError(fmt.Errorf("provider %s not found", m.selectedModel.Provider.ID))
133+ providerConfig := config.ProviderConfig{
134+ ID: string(m.selectedModel.Provider.ID),
135+ Name: m.selectedModel.Provider.Name,
136+ APIKey: m.apiKeyValue,
137+ Type: provider.Type,
138+ BaseURL: provider.APIEndpoint,
140+ return m, tea.Sequence(
141+ util.CmdHandler(APIKeyStateChangeMsg{
142+ State: APIKeyInputStateVerifying,
146+ err := providerConfig.TestConnection(config.Get().Resolver())
147+ // intentionally wait for at least 750ms to make sure the user sees the spinner
148+ elapsed := time.Since(start)
149+ if elapsed < 750*time.Millisecond {
150+ time.Sleep(750*time.Millisecond - elapsed)
153+ m.isAPIKeyValid = true
154+ return APIKeyStateChangeMsg{
155+ State: APIKeyInputStateVerified,
158+ return APIKeyStateChangeMsg{
159+ State: APIKeyInputStateError,
164+ // Normal model selection
102 selectedItemInx := m.modelList.SelectedIndex()
165 selectedItemInx := m.modelList.SelectedIndex()
103 if selectedItemInx == list.NoSelection {
166 if selectedItemInx == list.NoSelection {
104 return m, nil
167 return m, nil
@@ -113,17 +176,32 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113 modelType = config.SelectedModelTypeSmall
176 modelType = config.SelectedModelTypeSmall
179+ // Check if provider is configured
180+ if m.isProviderConfigured(string(selectedItem.Provider.ID)) {
116− return m, tea.Sequence(
181+ return m, tea.Sequence(
117− util.CmdHandler(dialogs.CloseDialogMsg{}),
182+ util.CmdHandler(dialogs.CloseDialogMsg{}),
118− util.CmdHandler(ModelSelectedMsg{
183+ util.CmdHandler(ModelSelectedMsg{
119− Model: config.SelectedModel{
184+ Model: config.SelectedModel{
120− Model: selectedItem.Model.ID,
185+ Model: selectedItem.Model.ID,
121− Provider: string(selectedItem.Provider.ID),
186+ Provider: string(selectedItem.Provider.ID),
123− ModelType: modelType,
188+ ModelType: modelType,
192+ // Provider not configured, show API key input
193+ m.needsAPIKey = true
194+ m.selectedModel = &selectedItem
195+ m.selectedModelType = modelType
196+ m.apiKeyInput.SetProviderName(selectedItem.Provider.Name)
126 case key.Matches(msg, m.keyMap.Tab):
199 case key.Matches(msg, m.keyMap.Tab):
201+ u, cmd := m.apiKeyInput.Update(msg)
202+ m.apiKeyInput = u.(*APIKeyInput)
127 if m.modelList.GetModelType() == LargeModelType {
205 if m.modelList.GetModelType() == LargeModelType {
128 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
206 m.modelList.SetInputPlaceholder(smallModelInputPlaceholder)
129 return m, m.modelList.SetModelType(SmallModelType)
207 return m, m.modelList.SetModelType(SmallModelType)
@@ -132,18 +210,68 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
132 return m, m.modelList.SetModelType(LargeModelType)
210 return m, m.modelList.SetModelType(LargeModelType)
134 case key.Matches(msg, m.keyMap.Close):
212 case key.Matches(msg, m.keyMap.Close):
214+ if m.isAPIKeyValid {
217+ // Go back to model selection
218+ m.needsAPIKey = false
219+ m.selectedModel = nil
220+ m.isAPIKeyValid = false
222+ m.apiKeyInput.Reset()
135 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
225 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
228+ u, cmd := m.apiKeyInput.Update(msg)
229+ m.apiKeyInput = u.(*APIKeyInput)
232+ u, cmd := m.modelList.Update(msg)
137− u, cmd := m.modelList.Update(msg)
233+ m.modelList = u
138− m.modelList = u
234+ return m, cmd
239+ u, cmd := m.apiKeyInput.Update(msg)
240+ m.apiKeyInput = u.(*APIKeyInput)
244+ m.modelList, cmd = m.modelList.Update(msg)
139 return m, cmd
245 return m, cmd
247+ case spinner.TickMsg:
248+ u, cmd := m.apiKeyInput.Update(msg)
249+ m.apiKeyInput = u.(*APIKeyInput)
142 return m, nil
252 return m, nil
145func (m *modelDialogCmp) View() string {
255func (m *modelDialogCmp) View() string {
146 t := styles.CurrentTheme()
256 t := styles.CurrentTheme()
259+ // Show API key input
260+ m.keyMap.isAPIKeyHelp = true
261+ m.keyMap.isAPIKeyValid = m.isAPIKeyValid
262+ apiKeyView := m.apiKeyInput.View()
263+ apiKeyView = t.S().Base.Width(m.width - 3).Height(lipgloss.Height(apiKeyView)).PaddingLeft(1).Render(apiKeyView)
264+ content := lipgloss.JoinVertical(
266+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
269+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
271+ return m.style().Render(content)
274+ // Show model selection
147 listView := m.modelList.View()
275 listView := m.modelList.View()
148 radio := m.modelTypeRadio()
276 radio := m.modelTypeRadio()
149 content := lipgloss.JoinVertical(
277 content := lipgloss.JoinVertical(
@@ -157,10 +285,18 @@ func (m *modelDialogCmp) View() string {
159func (m *modelDialogCmp) Cursor() *tea.Cursor {
287func (m *modelDialogCmp) Cursor() *tea.Cursor {
289+ cursor := m.apiKeyInput.Cursor()
291+ cursor = m.moveCursor(cursor)
160− cursor := m.modelList.Cursor()
295+ cursor := m.modelList.Cursor()
161− if cursor != nil {
296+ if cursor != nil {
162− cursor = m.moveCursor(cursor)
297+ cursor = m.moveCursor(cursor)
163− return cursor
298+ return cursor
165 return nil
301 return nil
@@ -192,9 +328,15 @@ func (m *modelDialogCmp) Position() (int, int) {
193func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
329func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
194 row, col := m.Position()
330 row, col := m.Position()
195− offset := row + 3 // Border + title
332+ offset := row + 3 // Border + title + API key input offset
334+ cursor.X = cursor.X + col + 2
336+ offset := row + 3 // Border + title
196− cursor.Y += offset
337+ cursor.Y += offset
197− cursor.X = cursor.X + col + 2
338+ cursor.X = cursor.X + col + 2
198 return cursor
340 return cursor
@@ -212,3 +354,49 @@ func (m *modelDialogCmp) modelTypeRadio() string {
213 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
355 return t.S().Base.Foreground(t.FgHalfMuted).Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
358+func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
360+ if _, ok := cfg.Providers[providerID]; ok {
366+func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
367+ providers, err := config.Providers()
371+ for _, p := range providers {
372+ if p.ID == providerID {
379+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
380+ if m.selectedModel == nil {
381+ return util.ReportError(fmt.Errorf("no model selected"))
385+ err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
387+ return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
390+ // Reset API key state and continue with model selection
391+ selectedModel := *m.selectedModel
392+ return tea.Sequence(
393+ util.CmdHandler(dialogs.CloseDialogMsg{}),
394+ util.CmdHandler(ModelSelectedMsg{
395+ Model: config.SelectedModel{
396+ Model: selectedModel.Model.ID,
397+ Provider: string(selectedModel.Provider.ID),
399+ ModelType: m.selectedModelType,