on main help sign in
feat: verify the API key when setting provider
1a656deKujtim Hoxha
internal/config/config.go+52
@@ -1,10 +1,13 @@
1package config
1package config
2
2
3import (
3import (
4+ "context"
4 "fmt"
5 "fmt"
6+ "net/http"
5 "os"
7 "os"
6 "slices"
8 "slices"
7 "strings"
9 "strings"
10+ "time"
8
11
9 "github.com/charmbracelet/crush/internal/env"
12 "github.com/charmbracelet/crush/internal/env"
10 "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -435,3 +438,52 @@ func (c *Config) SetupAgents() {
435 }
438 }
436 c.Agents = agents
439 c.Agents = agents
437}
440}
441+
442+func (c *Config) Resolver() VariableResolver {
443+ return c.resolver
444+}
445+
446+func (c *ProviderConfig) TestConnection(resolver VariableResolver) error {
447+ testURL := ""
448+ headers := make(map[string]string)
449+ apiKey, _ := resolver.ResolveValue(c.APIKey)
450+ switch c.Type {
451+ case provider.TypeOpenAI:
452+ baseURL, _ := resolver.ResolveValue(c.BaseURL)
453+ if baseURL == "" {
454+ baseURL = "https://api.openai.com/v1"
455+ }
456+ testURL = baseURL + "/models"
457+ headers["Authorization"] = "Bearer " + apiKey
458+ case provider.TypeAnthropic:
459+ baseURL, _ := resolver.ResolveValue(c.BaseURL)
460+ if baseURL == "" {
461+ baseURL = "https://api.anthropic.com/v1"
462+ }
463+ testURL = baseURL + "/models"
464+ headers["x-api-key"] = apiKey
465+ headers["anthropic-version"] = "2023-06-01"
466+ }
467+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
468+ defer cancel()
469+ client := &http.Client{}
470+ req, err := http.NewRequestWithContext(ctx, "GET", testURL, nil)
471+ if err != nil {
472+ return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
473+ }
474+ for k, v := range headers {
475+ req.Header.Set(k, v)
476+ }
477+ for k, v := range c.ExtraHeaders {
478+ req.Header.Set(k, v)
479+ }
480+ b, err := client.Do(req)
481+ if err != nil {
482+ return fmt.Errorf("failed to create request for provider %s: %w", c.ID, err)
483+ }
484+ if b.StatusCode != http.StatusOK {
485+ return fmt.Errorf("failed to connect to provider %s: %s", c.ID, b.Status)
486+ }
487+ _ = b.Body.Close()
488+ return nil
489+}
internal/config/init.go+1−1
@@ -109,5 +109,5 @@ func HasInitialDataConfig() bool {
109 if _, err := os.Stat(cfgPath); err != nil {
109 if _, err := os.Stat(cfgPath); err != nil {
110 return false
110 return false
111 }
111 }
112− return true
112+ return Get().IsConfigured()
113}
113}
internal/config/load.go+30
@@ -9,6 +9,7 @@ import (
9 "runtime"
9 "runtime"
10 "slices"
10 "slices"
11 "strings"
11 "strings"
12+ "sync"
12
13
13 "github.com/charmbracelet/crush/internal/env"
14 "github.com/charmbracelet/crush/internal/env"
14 "github.com/charmbracelet/crush/internal/fur/client"
15 "github.com/charmbracelet/crush/internal/fur/client"
@@ -75,6 +76,35 @@ func Load(workingDir string, debug bool) (*Config, error) {
75 return nil, fmt.Errorf("failed to configure providers: %w", err)
76 return nil, fmt.Errorf("failed to configure providers: %w", err)
76 }
77 }
77
78
79+ // Test provider connections in parallel
80+ var testResults sync.Map
81+ var wg sync.WaitGroup
82+
83+ for _, p := range cfg.Providers {
84+ if p.Type == provider.TypeOpenAI || p.Type == provider.TypeAnthropic {
85+ wg.Add(1)
86+ go func(provider ProviderConfig) {
87+ defer wg.Done()
88+ err := provider.TestConnection(cfg.resolver)
89+ testResults.Store(provider.ID, err == nil)
90+ if err != nil {
91+ slog.Error("Provider connection test failed", "provider", provider.ID, "error", err)
92+ }
93+ }(p)
94+ }
95+ }
96+ wg.Wait()
97+
98+ // Remove failed providers
99+ testResults.Range(func(key, value any) bool {
100+ providerID := key.(string)
101+ passed := value.(bool)
102+ if !passed {
103+ delete(cfg.Providers, providerID)
104+ }
105+ return true
106+ })
107+
78 if !cfg.IsConfigured() {
108 if !cfg.IsConfigured() {
79 slog.Warn("No providers configured")
109 slog.Warn("No providers configured")
80 return cfg, nil
110 return cfg, nil
internal/tui/components/chat/splash/splash.go+97−4
@@ -5,8 +5,10 @@ import (
5 "os"
5 "os"
6 "slices"
6 "slices"
7 "strings"
7 "strings"
8+ "time"
8
9
9 "github.com/charmbracelet/bubbles/v2/key"
10 "github.com/charmbracelet/bubbles/v2/key"
11+ "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
12 tea "github.com/charmbracelet/bubbletea/v2"
11 "github.com/charmbracelet/crush/internal/config"
13 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/fur/provider"
14 "github.com/charmbracelet/crush/internal/fur/provider"
@@ -36,6 +38,9 @@ type Splash interface {
36
38
37 // Showing API key input
39 // Showing API key input
38 IsShowingAPIKey() bool
40 IsShowingAPIKey() bool
41+
42+ // IsAPIKeyValid returns whether the API key is valid
43+ IsAPIKeyValid() bool
39}
44}
40
45
41const (
46const (
@@ -45,7 +50,10 @@ const (
45)
50)
46
51
47// OnboardingCompleteMsg is sent when onboarding is complete
52// OnboardingCompleteMsg is sent when onboarding is complete
53+type (
48−type OnboardingCompleteMsg struct{}
54+ OnboardingCompleteMsg struct{}
55+ SubmitAPIKeyMsg struct{}
56+)
49
57
50type splashCmp struct {
58type splashCmp struct {
51 width, height int
59 width, height int
@@ -62,6 +70,8 @@ type splashCmp struct {
62 modelList *models.ModelListComponent
70 modelList *models.ModelListComponent
63 apiKeyInput *models.APIKeyInput
71 apiKeyInput *models.APIKeyInput
64 selectedModel *models.ModelOption
72 selectedModel *models.ModelOption
73+ isAPIKeyValid bool
74+ apiKeyValue string
65}
75}
66
76
67func New() Splash {
77func New() Splash {
@@ -141,6 +151,7 @@ func (s *splashCmp) SetSize(width int, height int) tea.Cmd {
141 // remove padding, logo height, gap, title space
151 // remove padding, logo height, gap, title space
142 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
152 s.listHeight = s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) - s.logoGap() - 2
143 listWidth := min(60, width)
153 listWidth := min(60, width)
154+ s.apiKeyInput.SetWidth(width - 2)
144 return s.modelList.SetSize(listWidth, s.listHeight)
155 return s.modelList.SetSize(listWidth, s.listHeight)
145}
156}
146
157
@@ -149,16 +160,38 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
149 switch msg := msg.(type) {
160 switch msg := msg.(type) {
150 case tea.WindowSizeMsg:
161 case tea.WindowSizeMsg:
151 return s, s.SetSize(msg.Width, msg.Height)
162 return s, s.SetSize(msg.Width, msg.Height)
163+ case models.APIKeyStateChangeMsg:
164+ u, cmd := s.apiKeyInput.Update(msg)
165+ s.apiKeyInput = u.(*models.APIKeyInput)
166+ if msg.State == models.APIKeyInputStateVerified {
167+ return s, tea.Tick(5*time.Second, func(t time.Time) tea.Msg {
168+ return SubmitAPIKeyMsg{}
169+ })
170+ }
171+ return s, cmd
172+ case SubmitAPIKeyMsg:
173+ if s.isAPIKeyValid {
174+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
175+ }
152 case tea.KeyPressMsg:
176 case tea.KeyPressMsg:
153 switch {
177 switch {
154 case key.Matches(msg, s.keyMap.Back):
178 case key.Matches(msg, s.keyMap.Back):
179+ if s.isAPIKeyValid {
180+ return s, nil
181+ }
155 if s.needsAPIKey {
182 if s.needsAPIKey {
156 // Go back to model selection
183 // Go back to model selection
157 s.needsAPIKey = false
184 s.needsAPIKey = false
158 s.selectedModel = nil
185 s.selectedModel = nil
186+ s.isAPIKeyValid = false
187+ s.apiKeyValue = ""
188+ s.apiKeyInput.Reset()
159 return s, nil
189 return s, nil
160 }
190 }
161 case key.Matches(msg, s.keyMap.Select):
191 case key.Matches(msg, s.keyMap.Select):
192+ if s.isAPIKeyValid {
193+ return s, s.saveAPIKeyAndContinue(s.apiKeyValue)
194+ }
162 if s.isOnboarding && !s.needsAPIKey {
195 if s.isOnboarding && !s.needsAPIKey {
163 modelInx := s.modelList.SelectedIndex()
196 modelInx := s.modelList.SelectedIndex()
164 items := s.modelList.Items()
197 items := s.modelList.Items()
@@ -176,23 +209,75 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
176 }
209 }
177 } else if s.needsAPIKey {
210 } else if s.needsAPIKey {
178 // Handle API key submission
211 // Handle API key submission
179− apiKey := s.apiKeyInput.Value()
212+ s.apiKeyValue = strings.TrimSpace(s.apiKeyInput.Value())
180− if apiKey != "" {
213+ if s.apiKeyValue == "" {
181− return s, s.saveAPIKeyAndContinue(apiKey)
214+ return s, nil
182 }
215 }
216+
217+ provider, err := s.getProvider(s.selectedModel.Provider.ID)
218+ if err != nil || provider == nil {
219+ return s, util.ReportError(fmt.Errorf("provider %s not found", s.selectedModel.Provider.ID))
220+ }
221+ providerConfig := config.ProviderConfig{
222+ ID: string(s.selectedModel.Provider.ID),
223+ Name: s.selectedModel.Provider.Name,
224+ APIKey: s.apiKeyValue,
225+ Type: provider.Type,
226+ BaseURL: provider.APIEndpoint,
227+ }
228+ return s, tea.Sequence(
229+ util.CmdHandler(models.APIKeyStateChangeMsg{
230+ State: models.APIKeyInputStateVerifying,
231+ }),
232+ func() tea.Msg {
233+ start := time.Now()
234+ err := providerConfig.TestConnection(config.Get().Resolver())
235+ // intentionally wait for at least 750ms to make sure the user sees the spinner
236+ elapsed := time.Since(start)
237+ if elapsed < 750*time.Millisecond {
238+ time.Sleep(750*time.Millisecond - elapsed)
239+ }
240+ if err == nil {
241+ s.isAPIKeyValid = true
242+ return models.APIKeyStateChangeMsg{
243+ State: models.APIKeyInputStateVerified,
244+ }
245+ }
246+ return models.APIKeyStateChangeMsg{
247+ State: models.APIKeyInputStateError,
248+ }
249+ },
250+ )
183 } else if s.needsProjectInit {
251 } else if s.needsProjectInit {
184 return s, s.initializeProject()
252 return s, s.initializeProject()
185 }
253 }
186 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
254 case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
255+ if s.needsAPIKey {
256+ u, cmd := s.apiKeyInput.Update(msg)
257+ s.apiKeyInput = u.(*models.APIKeyInput)
258+ return s, cmd
259+ }
187 if s.needsProjectInit {
260 if s.needsProjectInit {
188 s.selectedNo = !s.selectedNo
261 s.selectedNo = !s.selectedNo
189 return s, nil
262 return s, nil
190 }
263 }
191 case key.Matches(msg, s.keyMap.Yes):
264 case key.Matches(msg, s.keyMap.Yes):
265+ if s.needsAPIKey {
266+ u, cmd := s.apiKeyInput.Update(msg)
267+ s.apiKeyInput = u.(*models.APIKeyInput)
268+ return s, cmd
269+ }
270+
192 if s.needsProjectInit {
271 if s.needsProjectInit {
193 return s, s.initializeProject()
272 return s, s.initializeProject()
194 }
273 }
195 case key.Matches(msg, s.keyMap.No):
274 case key.Matches(msg, s.keyMap.No):
275+ if s.needsAPIKey {
276+ u, cmd := s.apiKeyInput.Update(msg)
277+ s.apiKeyInput = u.(*models.APIKeyInput)
278+ return s, cmd
279+ }
280+
196 s.selectedNo = true
281 s.selectedNo = true
197 return s, s.initializeProject()
282 return s, s.initializeProject()
198 default:
283 default:
@@ -216,6 +301,10 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
216 s.modelList, cmd = s.modelList.Update(msg)
301 s.modelList, cmd = s.modelList.Update(msg)
217 return s, cmd
302 return s, cmd
218 }
303 }
304+ case spinner.TickMsg:
305+ u, cmd := s.apiKeyInput.Update(msg)
306+ s.apiKeyInput = u.(*models.APIKeyInput)
307+ return s, cmd
219 }
308 }
220 return s, nil
309 return s, nil
221}
310}
@@ -629,3 +718,7 @@ func (s *splashCmp) mcpBlock() string {
629func (s *splashCmp) IsShowingAPIKey() bool {
718func (s *splashCmp) IsShowingAPIKey() bool {
630 return s.needsAPIKey
719 return s.needsAPIKey
631}
720}
721+
722+func (s *splashCmp) IsAPIKeyValid() bool {
723+ return s.isAPIKeyValid
724+}
internal/tui/components/dialogs/models/apikey.go+129−23
@@ -4,6 +4,7 @@ import (
4 "fmt"
4 "fmt"
5 "strings"
5 "strings"
6
6
7+ "github.com/charmbracelet/bubbles/v2/spinner"
7 "github.com/charmbracelet/bubbles/v2/textinput"
8 "github.com/charmbracelet/bubbles/v2/textinput"
8 tea "github.com/charmbracelet/bubbletea/v2"
9 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/crush/internal/config"
10 "github.com/charmbracelet/crush/internal/config"
@@ -11,11 +12,27 @@ import (
11 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
12)
13)
13
14
15+type APIKeyInputState int
16+
17+const (
18+ APIKeyInputStateInitial APIKeyInputState = iota
19+ APIKeyInputStateVerifying
20+ APIKeyInputStateVerified
21+ APIKeyInputStateError
22+)
23+
24+type APIKeyStateChangeMsg struct {
25+ State APIKeyInputState
26+}
27+
14type APIKeyInput struct {
28type APIKeyInput struct {
15 input textinput.Model
29 input textinput.Model
16 width int
30 width int
17− height int
31+ spinner spinner.Model
18 providerName string
32 providerName string
33+ state APIKeyInputState
34+ title string
35+ showTitle bool
19}
36}
20
37
21func NewAPIKeyInput() *APIKeyInput {
38func NewAPIKeyInput() *APIKeyInput {
@@ -23,32 +40,59 @@ func NewAPIKeyInput() *APIKeyInput {
23
40
24 ti := textinput.New()
41 ti := textinput.New()
25 ti.Placeholder = "Enter your API key..."
42 ti.Placeholder = "Enter your API key..."
26− ti.SetWidth(50)
27 ti.SetVirtualCursor(false)
43 ti.SetVirtualCursor(false)
28 ti.Prompt = "> "
44 ti.Prompt = "> "
29 ti.SetStyles(t.S().TextInput)
45 ti.SetStyles(t.S().TextInput)
30 ti.Focus()
46 ti.Focus()
31
47
32 return &APIKeyInput{
48 return &APIKeyInput{
33− input: ti,
49+ input: ti,
34− width: 60,
50+ state: APIKeyInputStateInitial,
51+ spinner: spinner.New(
52+ spinner.WithSpinner(spinner.Dot),
53+ spinner.WithStyle(t.S().Base.Foreground(t.Green)),
54+ ),
35 providerName: "Provider",
55 providerName: "Provider",
56+ showTitle: true,
36 }
57 }
37}
58}
38
59
39func (a *APIKeyInput) SetProviderName(name string) {
60func (a *APIKeyInput) SetProviderName(name string) {
40 a.providerName = name
61 a.providerName = name
62+ a.updateStatePresentation()
63+}
64+
65+func (a *APIKeyInput) SetShowTitle(show bool) {
66+ a.showTitle = show
67+}
68+
69+func (a *APIKeyInput) GetTitle() string {
70+ return a.title
41}
71}
42
72
43func (a *APIKeyInput) Init() tea.Cmd {
73func (a *APIKeyInput) Init() tea.Cmd {
44− return textinput.Blink
74+ a.updateStatePresentation()
75+ return a.spinner.Tick
45}
76}
46
77
47func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
78func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
48 switch msg := msg.(type) {
79 switch msg := msg.(type) {
49− case tea.WindowSizeMsg:
80+ case spinner.TickMsg:
50− a.width = msg.Width
81+ if a.state == APIKeyInputStateVerifying {
51− a.height = msg.Height
82+ var cmd tea.Cmd
83+ a.spinner, cmd = a.spinner.Update(msg)
84+ a.updateStatePresentation()
85+ return a, cmd
86+ }
87+ return a, nil
88+ case APIKeyStateChangeMsg:
89+ a.state = msg.State
90+ var cmd tea.Cmd
91+ if msg.State == APIKeyInputStateVerifying {
92+ cmd = a.spinner.Tick
93+ }
94+ a.updateStatePresentation()
95+ return a, cmd
52 }
96 }
53
97
54 var cmd tea.Cmd
98 var cmd tea.Cmd
@@ -56,36 +100,79 @@ func (a *APIKeyInput) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
56 return a, cmd
100 return a, cmd
57}
101}
58
102
59−func (a *APIKeyInput) View() string {
103+func (a *APIKeyInput) updateStatePresentation() {
60 t := styles.CurrentTheme()
104 t := styles.CurrentTheme()
61
105
62− title := t.S().Base.
106+ prefixStyle := t.S().Base.
63− Foreground(t.Primary).
107+ Foreground(t.Primary)
64− Bold(true).
108+ accentStyle := t.S().Base.Foreground(t.Green).Bold(true)
65− Render(fmt.Sprintf("Enter your %s API Key", a.providerName))
109+ errorStyle := t.S().Base.Foreground(t.Cherry)
110+
111+ switch a.state {
112+ case APIKeyInputStateInitial:
113+ titlePrefix := prefixStyle.Render("Enter your ")
114+ a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(".")
115+ a.input.SetStyles(t.S().TextInput)
116+ a.input.Prompt = "> "
117+ case APIKeyInputStateVerifying:
118+ titlePrefix := prefixStyle.Render("Verifying your ")
119+ a.title = titlePrefix + accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render("...")
120+ ts := t.S().TextInput
121+ // make the blurred state be the same
122+ ts.Blurred.Prompt = ts.Focused.Prompt
123+ a.input.Prompt = a.spinner.View()
124+ a.input.Blur()
125+ case APIKeyInputStateVerified:
126+ a.title = accentStyle.Render(a.providerName+" API Key") + prefixStyle.Render(" validated.")
127+ ts := t.S().TextInput
128+ // make the blurred state be the same
129+ ts.Blurred.Prompt = ts.Focused.Prompt
130+ a.input.SetStyles(ts)
131+ a.input.Prompt = styles.CheckIcon + " "
132+ a.input.Blur()
133+ case APIKeyInputStateError:
134+ a.title = errorStyle.Render("Invalid ") + accentStyle.Render(a.providerName+" API Key") + errorStyle.Render(". Try again?")
135+ ts := t.S().TextInput
136+ ts.Focused.Prompt = ts.Focused.Prompt.Foreground(t.Cherry)
137+ a.input.Focus()
138+ a.input.SetStyles(ts)
139+ a.input.Prompt = styles.ErrorIcon + " "
140+ }
141+}
66
142
143+func (a *APIKeyInput) View() string {
67 inputView := a.input.View()
144 inputView := a.input.View()
68
145
69 dataPath := config.GlobalConfigData()
146 dataPath := config.GlobalConfigData()
70 dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1)
147 dataPath = strings.Replace(dataPath, config.HomeDir(), "~", 1)
71− helpText := t.S().Muted.
148+ helpText := styles.CurrentTheme().S().Muted.
72 Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
149 Render(fmt.Sprintf("This will be written to the global configuration: %s", dataPath))
73
150
151+ var content string
152+ if a.showTitle && a.title != "" {
74− content := lipgloss.JoinVertical(
153+ content = lipgloss.JoinVertical(
75− lipgloss.Left,
154+ lipgloss.Left,
76− title,
155+ a.title,
77− "",
156+ "",
78− inputView,
157+ inputView,
79− "",
158+ "",
80− helpText,
159+ helpText,
160+ )
161+ } else {
162+ content = lipgloss.JoinVertical(
163+ lipgloss.Left,
164+ inputView,
165+ "",
166+ helpText,
81− )
167+ )
168+ }
82
169
83 return content
170 return content
84}
171}
85
172
86func (a *APIKeyInput) Cursor() *tea.Cursor {
173func (a *APIKeyInput) Cursor() *tea.Cursor {
87 cursor := a.input.Cursor()
174 cursor := a.input.Cursor()
88− if cursor != nil {
175+ if cursor != nil && a.showTitle {
89 cursor.Y += 2 // Adjust for title and spacing
176 cursor.Y += 2 // Adjust for title and spacing
90 }
177 }
91 return cursor
178 return cursor
@@ -94,3 +181,22 @@ func (a *APIKeyInput) Cursor() *tea.Cursor {
94func (a *APIKeyInput) Value() string {
181func (a *APIKeyInput) Value() string {
95 return a.input.Value()
182 return a.input.Value()
96}
183}
184+
185+func (a *APIKeyInput) Tick() tea.Cmd {
186+ if a.state == APIKeyInputStateVerifying {
187+ return a.spinner.Tick
188+ }
189+ return nil
190+}
191+
192+func (a *APIKeyInput) SetWidth(width int) {
193+ a.width = width
194+ a.input.SetWidth(width - 4)
195+}
196+
197+func (a *APIKeyInput) Reset() {
198+ a.state = APIKeyInputStateInitial
199+ a.input.SetValue("")
200+ a.input.Focus()
201+ a.updateStatePresentation()
202+}
internal/tui/components/dialogs/models/keys.go+12
@@ -10,6 +10,9 @@ type KeyMap struct {
10 Previous,
10 Previous,
11 Tab,
11 Tab,
12 Close key.Binding
12 Close key.Binding
13+
14+ isAPIKeyHelp bool
15+ isAPIKeyValid bool
13}
16}
14
17
15func DefaultKeyMap() KeyMap {
18func DefaultKeyMap() KeyMap {
@@ -61,6 +64,15 @@ func (k KeyMap) FullHelp() [][]key.Binding {
61
64
62// ShortHelp implements help.KeyMap.
65// ShortHelp implements help.KeyMap.
63func (k KeyMap) ShortHelp() []key.Binding {
66func (k KeyMap) ShortHelp() []key.Binding {
67+ if k.isAPIKeyHelp && !k.isAPIKeyValid {
68+ return []key.Binding{
69+ k.Close,
70+ }
71+ } else if k.isAPIKeyValid {
72+ return []key.Binding{
73+ k.Select,
74+ }
75+ }
64 return []key.Binding{
76 return []key.Binding{
65 key.NewBinding(
77 key.NewBinding(
66 key.WithKeys("down", "up"),
78 key.WithKeys("down", "up"),
internal/tui/components/dialogs/models/models.go+212−24
@@ -1,8 +1,12 @@
1package models
1package models
2
2
3import (
3import (
4+ "fmt"
5+ "time"
6+
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
63+
64+ // API key state
65+ needsAPIKey bool
66+ apiKeyInput *APIKeyInput
67+ selectedModel *ModelOption
68+ selectedModelType config.SelectedModelType
69+ isAPIKeyValid bool
70+ apiKeyValue string
59}
71}
60
72
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
80
94
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,
86 }
101 }
87}
102}
88
103
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())
91}
106}
92
107
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)
119+ return m, cmd
99 case tea.KeyPressMsg:
120 case tea.KeyPressMsg:
100 switch {
121 switch {
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)
125+ }
126+ if m.needsAPIKey {
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))
132+ }
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,
139+ }
140+ return m, tea.Sequence(
141+ util.CmdHandler(APIKeyStateChangeMsg{
142+ State: APIKeyInputStateVerifying,
143+ }),
144+ func() tea.Msg {
145+ start := time.Now()
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)
151+ }
152+ if err == nil {
153+ m.isAPIKeyValid = true
154+ return APIKeyStateChangeMsg{
155+ State: APIKeyInputStateVerified,
156+ }
157+ }
158+ return APIKeyStateChangeMsg{
159+ State: APIKeyInputStateError,
160+ }
161+ },
162+ )
163+ }
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
114 }
177 }
115
178
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),
122− },
187+ },
123− ModelType: modelType,
188+ ModelType: modelType,
124− }),
189+ }),
125− )
190+ )
191+ } else {
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)
197+ return m, nil
198+ }
126 case key.Matches(msg, m.keyMap.Tab):
199 case key.Matches(msg, m.keyMap.Tab):
200+ if m.needsAPIKey {
201+ u, cmd := m.apiKeyInput.Update(msg)
202+ m.apiKeyInput = u.(*APIKeyInput)
203+ return m, cmd
204+ }
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)
133 }
211 }
134 case key.Matches(msg, m.keyMap.Close):
212 case key.Matches(msg, m.keyMap.Close):
213+ if m.needsAPIKey {
214+ if m.isAPIKeyValid {
215+ return m, nil
216+ }
217+ // Go back to model selection
218+ m.needsAPIKey = false
219+ m.selectedModel = nil
220+ m.isAPIKeyValid = false
221+ m.apiKeyValue = ""
222+ m.apiKeyInput.Reset()
223+ return m, nil
224+ }
135 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
225 return m, util.CmdHandler(dialogs.CloseDialogMsg{})
136 default:
226 default:
227+ if m.needsAPIKey {
228+ u, cmd := m.apiKeyInput.Update(msg)
229+ m.apiKeyInput = u.(*APIKeyInput)
230+ return m, cmd
231+ } else {
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
235+ }
236+ }
237+ case tea.PasteMsg:
238+ if m.needsAPIKey {
239+ u, cmd := m.apiKeyInput.Update(msg)
240+ m.apiKeyInput = u.(*APIKeyInput)
241+ return m, cmd
242+ } else {
243+ var cmd tea.Cmd
244+ m.modelList, cmd = m.modelList.Update(msg)
139 return m, cmd
245 return m, cmd
140 }
246 }
247+ case spinner.TickMsg:
248+ u, cmd := m.apiKeyInput.Update(msg)
249+ m.apiKeyInput = u.(*APIKeyInput)
250+ return m, cmd
141 }
251 }
142 return m, nil
252 return m, nil
143}
253}
144
254
145func (m *modelDialogCmp) View() string {
255func (m *modelDialogCmp) View() string {
146 t := styles.CurrentTheme()
256 t := styles.CurrentTheme()
257+
258+ if m.needsAPIKey {
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(
265+ lipgloss.Left,
266+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title(m.apiKeyInput.GetTitle(), m.width-4)),
267+ apiKeyView,
268+ "",
269+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
270+ )
271+ return m.style().Render(content)
272+ }
273+
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 {
157}
285}
158
286
159func (m *modelDialogCmp) Cursor() *tea.Cursor {
287func (m *modelDialogCmp) Cursor() *tea.Cursor {
288+ if m.needsAPIKey {
289+ cursor := m.apiKeyInput.Cursor()
290+ if cursor != nil {
291+ cursor = m.moveCursor(cursor)
292+ return cursor
293+ }
294+ } else {
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
299+ }
164 }
300 }
165 return nil
301 return nil
166}
302}
@@ -192,9 +328,15 @@ func (m *modelDialogCmp) Position() (int, int) {
192
328
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()
331+ if m.needsAPIKey {
195− offset := row + 3 // Border + title
332+ offset := row + 3 // Border + title + API key input offset
333+ cursor.Y += offset
334+ cursor.X = cursor.X + col + 2
335+ } else {
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
339+ }
198 return cursor
340 return cursor
199}
341}
200
342
@@ -212,3 +354,49 @@ func (m *modelDialogCmp) modelTypeRadio() string {
212 }
354 }
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])
214}
356}
357+
358+func (m *modelDialogCmp) isProviderConfigured(providerID string) bool {
359+ cfg := config.Get()
360+ if _, ok := cfg.Providers[providerID]; ok {
361+ return true
362+ }
363+ return false
364+}
365+
366+func (m *modelDialogCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
367+ providers, err := config.Providers()
368+ if err != nil {
369+ return nil, err
370+ }
371+ for _, p := range providers {
372+ if p.ID == providerID {
373+ return &p, nil
374+ }
375+ }
376+ return nil, nil
377+}
378+
379+func (m *modelDialogCmp) saveAPIKeyAndContinue(apiKey string) tea.Cmd {
380+ if m.selectedModel == nil {
381+ return util.ReportError(fmt.Errorf("no model selected"))
382+ }
383+
384+ cfg := config.Get()
385+ err := cfg.SetProviderAPIKey(string(m.selectedModel.Provider.ID), apiKey)
386+ if err != nil {
387+ return util.ReportError(fmt.Errorf("failed to save API key: %w", err))
388+ }
389+
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),
398+ },
399+ ModelType: m.selectedModelType,
400+ }),
401+ )
402+}
internal/tui/page/chat/chat.go+38−8
@@ -25,6 +25,7 @@ import (
25 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
25 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
26 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
27 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
28+ "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
28 "github.com/charmbracelet/crush/internal/tui/page"
29 "github.com/charmbracelet/crush/internal/tui/page"
29 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/styles"
30 "github.com/charmbracelet/crush/internal/tui/util"
31 "github.com/charmbracelet/crush/internal/tui/util"
@@ -172,6 +173,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
172 return p, p.sendMessage(msg.Text, msg.Attachments)
173 return p, p.sendMessage(msg.Text, msg.Attachments)
173 case chat.SessionSelectedMsg:
174 case chat.SessionSelectedMsg:
174 return p, p.setSession(msg)
175 return p, p.setSession(msg)
176+ case splash.SubmitAPIKeyMsg:
177+ u, cmd := p.splash.Update(msg)
178+ p.splash = u.(splash.Splash)
179+ cmds = append(cmds, cmd)
180+ return p, tea.Batch(cmds...)
175 case commands.ToggleCompactModeMsg:
181 case commands.ToggleCompactModeMsg:
176 p.forceCompact = !p.forceCompact
182 p.forceCompact = !p.forceCompact
177 var cmd tea.Cmd
183 var cmd tea.Cmd
@@ -212,12 +218,25 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
212 cmds = append(cmds, cmd)
218 cmds = append(cmds, cmd)
213 return p, tea.Batch(cmds...)
219 return p, tea.Batch(cmds...)
214
220
221+ case models.APIKeyStateChangeMsg:
222+ if p.focusedPane == PanelTypeSplash {
223+ u, cmd := p.splash.Update(msg)
224+ p.splash = u.(splash.Splash)
225+ cmds = append(cmds, cmd)
226+ }
227+ return p, tea.Batch(cmds...)
215 case pubsub.Event[message.Message],
228 case pubsub.Event[message.Message],
216 anim.StepMsg,
229 anim.StepMsg,
217 spinner.TickMsg:
230 spinner.TickMsg:
231+ if p.focusedPane == PanelTypeSplash {
218− u, cmd := p.chat.Update(msg)
232+ u, cmd := p.splash.Update(msg)
233+ p.splash = u.(splash.Splash)
234+ cmds = append(cmds, cmd)
235+ } else {
236+ u, cmd := p.chat.Update(msg)
219− p.chat = u.(chat.MessageListCmp)
237+ p.chat = u.(chat.MessageListCmp)
220− cmds = append(cmds, cmd)
238+ cmds = append(cmds, cmd)
239+ }
221 return p, tea.Batch(cmds...)
240 return p, tea.Batch(cmds...)
222
241
223 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
242 case pubsub.Event[history.File], sidebar.SessionFilesMsg:
@@ -655,12 +674,23 @@ func (p *chatPage) Help() help.KeyMap {
655 fullList = append(fullList, []key.Binding{v})
674 fullList = append(fullList, []key.Binding{v})
656 }
675 }
657 case p.isOnboarding && p.splash.IsShowingAPIKey():
676 case p.isOnboarding && p.splash.IsShowingAPIKey():
677+ if p.splash.IsAPIKeyValid() {
678+ shortList = append(shortList,
679+ key.NewBinding(
680+ key.WithKeys("enter"),
681+ key.WithHelp("enter", "continue"),
682+ ),
683+ )
684+ } else {
685+ shortList = append(shortList,
686+ // Go back
687+ key.NewBinding(
688+ key.WithKeys("esc"),
689+ key.WithHelp("esc", "back"),
690+ ),
691+ )
692+ }
658 shortList = append(shortList,
693 shortList = append(shortList,
659− // Go back
660− key.NewBinding(
661− key.WithKeys("esc"),
662− key.WithHelp("esc", "back"),
663− ),
664 // Quit
694 // Quit
665 key.NewBinding(
695 key.NewBinding(
666 key.WithKeys("ctrl+c"),
696 key.WithKeys("ctrl+c"),
internal/tui/styles/crush.go+1
@@ -52,5 +52,6 @@ func NewCrushTheme() *Theme {
52 Red: charmtone.Coral,
52 Red: charmtone.Coral,
53 RedDark: charmtone.Sriracha,
53 RedDark: charmtone.Sriracha,
54 RedLight: charmtone.Salmon,
54 RedLight: charmtone.Salmon,
55+ Cherry: charmtone.Cherry,
55 }
56 }
56}
57}
internal/tui/styles/theme.go+7−6
@@ -72,6 +72,7 @@ type Theme struct {
72 Red color.Color
72 Red color.Color
73 RedDark color.Color
73 RedDark color.Color
74 RedLight color.Color
74 RedLight color.Color
75+ Cherry color.Color
75
76
76 styles *Styles
77 styles *Styles
77}
78}
@@ -150,15 +151,15 @@ func (t *Theme) buildStyles() *Styles {
150 TextInput: textinput.Styles{
151 TextInput: textinput.Styles{
151 Focused: textinput.StyleState{
152 Focused: textinput.StyleState{
152 Text: base,
153 Text: base,
153− Placeholder: base.Foreground(t.FgMuted),
154+ Placeholder: base.Foreground(t.FgSubtle),
154 Prompt: base.Foreground(t.Tertiary),
155 Prompt: base.Foreground(t.Tertiary),
155− Suggestion: base.Foreground(t.FgMuted),
156+ Suggestion: base.Foreground(t.FgSubtle),
156 },
157 },
157 Blurred: textinput.StyleState{
158 Blurred: textinput.StyleState{
158 Text: base.Foreground(t.FgMuted),
159 Text: base.Foreground(t.FgMuted),
159− Placeholder: base.Foreground(t.FgMuted),
160+ Placeholder: base.Foreground(t.FgSubtle),
160 Prompt: base.Foreground(t.FgMuted),
161 Prompt: base.Foreground(t.FgMuted),
161− Suggestion: base.Foreground(t.FgMuted),
162+ Suggestion: base.Foreground(t.FgSubtle),
162 },
163 },
163 Cursor: textinput.CursorStyle{
164 Cursor: textinput.CursorStyle{
164 Color: t.Secondary,
165 Color: t.Secondary,
@@ -173,7 +174,7 @@ func (t *Theme) buildStyles() *Styles {
173 LineNumber: base.Foreground(t.FgSubtle),
174 LineNumber: base.Foreground(t.FgSubtle),
174 CursorLine: base,
175 CursorLine: base,
175 CursorLineNumber: base.Foreground(t.FgSubtle),
176 CursorLineNumber: base.Foreground(t.FgSubtle),
176− Placeholder: base.Foreground(t.FgMuted),
177+ Placeholder: base.Foreground(t.FgSubtle),
177 Prompt: base.Foreground(t.Tertiary),
178 Prompt: base.Foreground(t.Tertiary),
178 },
179 },
179 Blurred: textarea.StyleState{
180 Blurred: textarea.StyleState{
@@ -182,7 +183,7 @@ func (t *Theme) buildStyles() *Styles {
182 LineNumber: base.Foreground(t.FgMuted),
183 LineNumber: base.Foreground(t.FgMuted),
183 CursorLine: base,
184 CursorLine: base,
184 CursorLineNumber: base.Foreground(t.FgMuted),
185 CursorLineNumber: base.Foreground(t.FgMuted),
185− Placeholder: base.Foreground(t.FgMuted),
186+ Placeholder: base.Foreground(t.FgSubtle),
186 Prompt: base.Foreground(t.FgMuted),
187 Prompt: base.Foreground(t.FgMuted),
187 },
188 },
188 Cursor: textarea.CursorStyle{
189 Cursor: textarea.CursorStyle{
internal/tui/tui.go+31−2
@@ -12,6 +12,7 @@ import (
12 "github.com/charmbracelet/crush/internal/permission"
12 "github.com/charmbracelet/crush/internal/permission"
13 "github.com/charmbracelet/crush/internal/pubsub"
13 "github.com/charmbracelet/crush/internal/pubsub"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
14 cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
15+ "github.com/charmbracelet/crush/internal/tui/components/chat/splash"
15 "github.com/charmbracelet/crush/internal/tui/components/completions"
16 "github.com/charmbracelet/crush/internal/tui/components/completions"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
@@ -48,8 +49,9 @@ type appModel struct {
48
49
49 app *app.App
50 app *app.App
50
51
51− dialog dialogs.DialogCmp
52+ dialog dialogs.DialogCmp
52− completions completions.Completions
53+ completions completions.Completions
54+ isConfigured bool
53
55
54 // Chat Page Specific
56 // Chat Page Specific
55 selectedSessionID string // The ID of the currently selected session
57 selectedSessionID string // The ID of the currently selected session
@@ -72,6 +74,7 @@ func (a appModel) Init() tea.Cmd {
72func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
74func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
73 var cmds []tea.Cmd
75 var cmds []tea.Cmd
74 var cmd tea.Cmd
76 var cmd tea.Cmd
77+ a.isConfigured = config.HasInitialDataConfig()
75
78
76 switch msg := msg.(type) {
79 switch msg := msg.(type) {
77 case tea.KeyboardEnhancementsMsg:
80 case tea.KeyboardEnhancementsMsg:
@@ -223,10 +226,28 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
223 }
226 }
224 }
227 }
225
228
229+ return a, tea.Batch(cmds...)
230+ case splash.OnboardingCompleteMsg:
231+ a.isConfigured = config.HasInitialDataConfig()
232+ updated, cmd := a.pages[a.currentPage].Update(msg)
233+ a.pages[a.currentPage] = updated.(util.Model)
234+ cmds = append(cmds, cmd)
226 return a, tea.Batch(cmds...)
235 return a, tea.Batch(cmds...)
227 // Key Press Messages
236 // Key Press Messages
228 case tea.KeyPressMsg:
237 case tea.KeyPressMsg:
229 return a, a.handleKeyPressMsg(msg)
238 return a, a.handleKeyPressMsg(msg)
239+
240+ case tea.PasteMsg:
241+ if a.dialog.HasDialogs() {
242+ u, dialogCmd := a.dialog.Update(msg)
243+ a.dialog = u.(dialogs.DialogCmp)
244+ cmds = append(cmds, dialogCmd)
245+ } else {
246+ updated, cmd := a.pages[a.currentPage].Update(msg)
247+ a.pages[a.currentPage] = updated.(util.Model)
248+ cmds = append(cmds, cmd)
249+ }
250+ return a, tea.Batch(cmds...)
230 }
251 }
231 s, _ := a.status.Update(msg)
252 s, _ := a.status.Update(msg)
232 a.status = s.(status.StatusCmp)
253 a.status = s.(status.StatusCmp)
@@ -307,6 +328,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
307 })
328 })
308
329
309 case key.Matches(msg, a.keyMap.Commands):
330 case key.Matches(msg, a.keyMap.Commands):
331+ // if the app is not configured show no commands
332+ if !a.isConfigured {
333+ return nil
334+ }
310 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
335 if a.dialog.ActiveDialogID() == commands.CommandsDialogID {
311 return util.CmdHandler(dialogs.CloseDialogMsg{})
336 return util.CmdHandler(dialogs.CloseDialogMsg{})
312 }
337 }
@@ -317,6 +342,10 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
317 Model: commands.NewCommandDialog(a.selectedSessionID),
342 Model: commands.NewCommandDialog(a.selectedSessionID),
318 })
343 })
319 case key.Matches(msg, a.keyMap.Sessions):
344 case key.Matches(msg, a.keyMap.Sessions):
345+ // if the app is not configured show no sessions
346+ if !a.isConfigured {
347+ return nil
348+ }
320 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
349 if a.dialog.ActiveDialogID() == sessions.SessionsDialogID {
321 return util.CmdHandler(dialogs.CloseDialogMsg{})
350 return util.CmdHandler(dialogs.CloseDialogMsg{})
322 }
351 }