on main help sign in
wip: init screen and simple flow
eeeef60Kujtim Hoxha
internal/config/config.go+9−8
@@ -208,7 +208,8 @@ type Config struct {
208 // TODO: most likely remove this concept when I come back to it
208 // TODO: most likely remove this concept when I come back to it
209 Agents map[string]Agent `json:"-"`
209 Agents map[string]Agent `json:"-"`
210 // TODO: find a better way to do this this should probably not be part of the config
210 // TODO: find a better way to do this this should probably not be part of the config
211− resolver VariableResolver
211+ resolver VariableResolver
212+ dataConfigDir string `json:"-"`
212}
213}
213
214
214func (c *Config) WorkingDir() string {
215func (c *Config) WorkingDir() string {
@@ -291,17 +292,17 @@ func (c *Config) Resolve(key string) (string, error) {
291 return c.resolver.ResolveValue(key)
292 return c.resolver.ResolveValue(key)
292}
293}
293
294
294−// TODO: maybe handle this better
295−func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
295+func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error {
296− cfg := Get()
297− cfg.Models[modelType] = model
296+ c.Models[modelType] = model
297+ if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil {
298+ return fmt.Errorf("failed to update preferred model: %w", err)
299+ }
298 return nil
300 return nil
299}
301}
300
302
301func (c *Config) SetConfigField(key string, value any) error {
303func (c *Config) SetConfigField(key string, value any) error {
302− configPath := GlobalConfigData()
303 // read the data
304 // read the data
304− data, err := os.ReadFile(configPath)
305+ data, err := os.ReadFile(c.dataConfigDir)
305 if err != nil {
306 if err != nil {
306 if os.IsNotExist(err) {
307 if os.IsNotExist(err) {
307 data = []byte("{}")
308 data = []byte("{}")
@@ -314,7 +315,7 @@ func (c *Config) SetConfigField(key string, value any) error {
314 if err != nil {
315 if err != nil {
315 return fmt.Errorf("failed to set config field %s: %w", key, err)
316 return fmt.Errorf("failed to set config field %s: %w", key, err)
316 }
317 }
317− if err := os.WriteFile(configPath, []byte(newValue), 0o644); err != nil {
318+ if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o644); err != nil {
318 return fmt.Errorf("failed to write config file: %w", err)
319 return fmt.Errorf("failed to write config file: %w", err)
319 }
320 }
320 return nil
321 return nil
internal/config/load.go+34−27
@@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) {
46 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
46 return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err)
47 }
47 }
48
48
49+ cfg.dataConfigDir = GlobalConfigData()
50+
49 cfg.setDefaults(workingDir)
51 cfg.setDefaults(workingDir)
50
52
51 if debug {
53 if debug {
@@ -367,10 +369,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la
367}
369}
368
370
369func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error {
371func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error {
370− large, small, err := cfg.defaultModelSelection(knownProviders)
372+ defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders)
371 if err != nil {
373 if err != nil {
372 return fmt.Errorf("failed to select default models: %w", err)
374 return fmt.Errorf("failed to select default models: %w", err)
373 }
375 }
376+ large, small := defaultLarge, defaultSmall
374
377
375 largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
378 largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge]
376 if largeModelConfigured {
379 if largeModelConfigured {
@@ -381,18 +384,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
381 large.Provider = largeModelSelected.Provider
384 large.Provider = largeModelSelected.Provider
382 }
385 }
383 model := cfg.GetModel(large.Provider, large.Model)
386 model := cfg.GetModel(large.Provider, large.Model)
387+ slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model)
388+ slog.Info("MOdel configured", "model", model)
384 if model == nil {
389 if model == nil {
385− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
390+ large = defaultLarge
391+ // override the model type to large
392+ err := cfg.UpdatePreferredModel(SelectedModelTypeLarge, large)
393+ if err != nil {
394+ return fmt.Errorf("failed to update preferred large model: %w", err)
386− }
395+ }
387− if largeModelSelected.MaxTokens > 0 {
388− large.MaxTokens = largeModelSelected.MaxTokens
389 } else {
396 } else {
397+ if largeModelSelected.MaxTokens > 0 {
398+ large.MaxTokens = largeModelSelected.MaxTokens
399+ } else {
390− large.MaxTokens = model.DefaultMaxTokens
400+ large.MaxTokens = model.DefaultMaxTokens
391− }
401+ }
392− if largeModelSelected.ReasoningEffort != "" {
402+ if largeModelSelected.ReasoningEffort != "" {
393− large.ReasoningEffort = largeModelSelected.ReasoningEffort
403+ large.ReasoningEffort = largeModelSelected.ReasoningEffort
404+ }
405+ large.Think = largeModelSelected.Think
394 }
406 }
395− large.Think = largeModelSelected.Think
396 }
407 }
397 smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
408 smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall]
398 if smallModelConfigured {
409 if smallModelConfigured {
@@ -405,25 +416,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e
405
416
406 model := cfg.GetModel(small.Provider, small.Model)
417 model := cfg.GetModel(small.Provider, small.Model)
407 if model == nil {
418 if model == nil {
408− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
419+ small = defaultSmall
420+ // override the model type to small
421+ err := cfg.UpdatePreferredModel(SelectedModelTypeSmall, small)
422+ if err != nil {
423+ return fmt.Errorf("failed to update preferred small model: %w", err)
409− }
424+ }
410− if smallModelSelected.MaxTokens > 0 {
411− small.MaxTokens = smallModelSelected.MaxTokens
412 } else {
425 } else {
426+ if smallModelSelected.MaxTokens > 0 {
427+ small.MaxTokens = smallModelSelected.MaxTokens
428+ } else {
413− small.MaxTokens = model.DefaultMaxTokens
429+ small.MaxTokens = model.DefaultMaxTokens
430+ }
431+ small.ReasoningEffort = smallModelSelected.ReasoningEffort
432+ small.Think = smallModelSelected.Think
414 }
433 }
415− small.ReasoningEffort = smallModelSelected.ReasoningEffort
416− small.Think = smallModelSelected.Think
417− }
418−
419− // validate the selected models
420− largeModel := cfg.GetModel(large.Provider, large.Model)
421− if largeModel == nil {
422− return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider)
423− }
424− smallModel := cfg.GetModel(small.Provider, small.Model)
425− if smallModel == nil {
426− return fmt.Errorf("small model %s not found for provider %s", small.Model, small.Provider)
427 }
434 }
428 cfg.Models[SelectedModelTypeLarge] = large
435 cfg.Models[SelectedModelTypeLarge] = large
429 cfg.Models[SelectedModelTypeSmall] = small
436 cfg.Models[SelectedModelTypeSmall] = small
internal/tui/components/chat/messages/messages.go+7
@@ -10,6 +10,7 @@ import (
10 "github.com/charmbracelet/lipgloss/v2"
10 "github.com/charmbracelet/lipgloss/v2"
11
11
12 "github.com/charmbracelet/crush/internal/config"
12 "github.com/charmbracelet/crush/internal/config"
13+ "github.com/charmbracelet/crush/internal/fur/provider"
13 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
@@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() tea.View {
296 infoMsg := t.S().Subtle.Render(duration.String())
297 infoMsg := t.S().Subtle.Render(duration.String())
297 icon := t.S().Subtle.Render(styles.ModelIcon)
298 icon := t.S().Subtle.Render(styles.ModelIcon)
298 model := config.Get().GetModel(m.message.Provider, m.message.Model)
299 model := config.Get().GetModel(m.message.Provider, m.message.Model)
300+ if model == nil {
301+ // This means the model is not configured anymore
302+ model = &provider.Model{
303+ Name: "Unknown Model",
304+ }
305+ }
299 modelFormatted := t.S().Muted.Render(model.Name)
306 modelFormatted := t.S().Muted.Render(model.Name)
300 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
307 assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
301 return tea.NewView(
308 return tea.NewView(
internal/tui/components/chat/splash/keys.go+21−1
@@ -7,7 +7,11 @@ import (
7type KeyMap struct {
7type KeyMap struct {
8 Select,
8 Select,
9 Next,
9 Next,
10− Previous key.Binding
10+ Previous,
11+ Yes,
12+ No,
13+ Tab,
14+ LeftRight key.Binding
11}
15}
12
16
13func DefaultKeyMap() KeyMap {
17func DefaultKeyMap() KeyMap {
@@ -24,5 +28,21 @@ func DefaultKeyMap() KeyMap {
24 key.WithKeys("up", "ctrl+p"),
28 key.WithKeys("up", "ctrl+p"),
25 key.WithHelp("↑", "previous item"),
29 key.WithHelp("↑", "previous item"),
26 ),
30 ),
31+ Yes: key.NewBinding(
32+ key.WithKeys("y"),
33+ key.WithHelp("y", "yes"),
34+ ),
35+ No: key.NewBinding(
36+ key.WithKeys("n"),
37+ key.WithHelp("n", "no"),
38+ ),
39+ Tab: key.NewBinding(
40+ key.WithKeys("tab"),
41+ key.WithHelp("tab", "switch"),
42+ ),
43+ LeftRight: key.NewBinding(
44+ key.WithKeys("left", "right"),
45+ key.WithHelp("←/→", "switch"),
46+ ),
27 }
47 }
28}
48}
internal/tui/components/chat/splash/splash.go+233−4
@@ -1,9 +1,16 @@
1package splash
1package splash
2
2
3import (
3import (
4+ "fmt"
5+ "slices"
6+
4 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/config"
9 "github.com/charmbracelet/crush/internal/config"
10+ "github.com/charmbracelet/crush/internal/fur/provider"
11+ "github.com/charmbracelet/crush/internal/tui/components/chat"
12+ "github.com/charmbracelet/crush/internal/tui/components/completions"
13+ "github.com/charmbracelet/crush/internal/tui/components/core"
7 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
14 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
8 "github.com/charmbracelet/crush/internal/tui/components/core/list"
15 "github.com/charmbracelet/crush/internal/tui/components/core/list"
9 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
16 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
@@ -43,6 +50,7 @@ type splashCmp struct {
43 state SplashScreenState
50 state SplashScreenState
44 modelList *models.ModelListComponent
51 modelList *models.ModelListComponent
45 cursorRow, cursorCol int
52 cursorRow, cursorCol int
53+ selectedNo bool // true if "No" button is selected in initialize state
46}
54}
47
55
48func New() Splash {
56func New() Splash {
@@ -67,6 +75,7 @@ func New() Splash {
67 state: SplashScreenStateOnboarding,
75 state: SplashScreenStateOnboarding,
68 logoRendered: "",
76 logoRendered: "",
69 modelList: modelList,
77 modelList: modelList,
78+ selectedNo: false,
70 }
79 }
71}
80}
72
81
@@ -83,8 +92,28 @@ func (s *splashCmp) Init() tea.Cmd {
83 } else {
92 } else {
84 s.state = SplashScreenStateReady
93 s.state = SplashScreenStateReady
85 }
94 }
95+ } else {
96+ providers, err := config.Providers()
97+ if err != nil {
98+ return util.ReportError(err)
99+ }
100+ filteredProviders := []provider.Provider{}
101+ simpleProviders := []string{
102+ "anthropic",
103+ "openai",
104+ "gemini",
105+ "xai",
106+ "openrouter",
107+ }
108+ for _, p := range providers {
109+ if slices.Contains(simpleProviders, string(p.ID)) {
110+ filteredProviders = append(filteredProviders, p)
111+ }
112+ }
113+ s.modelList.SetProviders(filteredProviders)
114+ return s.modelList.Init()
86 }
115 }
87− return s.modelList.Init()
116+ return nil
88}
117}
89
118
90// SetSize implements SplashPage.
119// SetSize implements SplashPage.
@@ -107,15 +136,157 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
107 return s, s.SetSize(msg.Width, msg.Height)
136 return s, s.SetSize(msg.Width, msg.Height)
108 case tea.KeyPressMsg:
137 case tea.KeyPressMsg:
109 switch {
138 switch {
139+ case key.Matches(msg, s.keyMap.Select):
140+ if s.state == SplashScreenStateOnboarding {
141+ modelInx := s.modelList.SelectedIndex()
142+ items := s.modelList.Items()
143+ selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption)
144+ if s.isProviderConfigured(string(selectedItem.Provider.ID)) {
145+ cmd := s.setPreferredModel(selectedItem)
146+ s.state = SplashScreenStateReady
147+ if b, err := config.ProjectNeedsInitialization(); err != nil {
148+ return s, tea.Batch(cmd, util.ReportError(err))
149+ } else if b {
150+ s.state = SplashScreenStateInitialize
151+ return s, cmd
152+ } else {
153+ s.state = SplashScreenStateReady
154+ return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{}))
155+ }
156+ }
157+ } else if s.state == SplashScreenStateInitialize {
158+ return s, s.initializeProject()
159+ }
160+ case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight):
161+ if s.state == SplashScreenStateInitialize {
162+ s.selectedNo = !s.selectedNo
163+ return s, nil
164+ }
165+ case key.Matches(msg, s.keyMap.Yes):
166+ if s.state == SplashScreenStateInitialize {
167+ return s, s.initializeProject()
168+ }
169+ case key.Matches(msg, s.keyMap.No):
170+ if s.state == SplashScreenStateInitialize {
171+ s.state = SplashScreenStateReady
172+ return s, util.CmdHandler(OnboardingCompleteMsg{})
173+ }
110 default:
174 default:
175+ if s.state == SplashScreenStateOnboarding {
111− u, cmd := s.modelList.Update(msg)
176+ u, cmd := s.modelList.Update(msg)
112− s.modelList = u
177+ s.modelList = u
113− return s, cmd
178+ return s, cmd
179+ }
114 }
180 }
115 }
181 }
116 return s, nil
182 return s, nil
117}
183}
118
184
185+func (s *splashCmp) initializeProject() tea.Cmd {
186+ s.state = SplashScreenStateReady
187+ prompt := `Please analyze this codebase and create a CRUSH.md file containing:
188+1. Build/lint/test commands - especially for running a single test
189+2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
190+
191+The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
192+If there's already a CRUSH.md, improve it.
193+If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
194+Add the .crush directory to the .gitignore file if it's not already there.`
195+
196+ // Mark the project as initialized
197+ if err := config.MarkProjectInitialized(); err != nil {
198+ return util.ReportError(err)
199+ }
200+ var cmds []tea.Cmd
201+
202+ cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{}))
203+ if !s.selectedNo {
204+ cmds = append(cmds,
205+ util.CmdHandler(chat.SessionClearedMsg{}),
206+ util.CmdHandler(chat.SendMsg{
207+ Text: prompt,
208+ }),
209+ )
210+ }
211+ return tea.Sequence(cmds...)
212+}
213+
214+func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd {
215+ cfg := config.Get()
216+ model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID)
217+ if model == nil {
218+ return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID))
219+ }
220+
221+ selectedModel := config.SelectedModel{
222+ Model: selectedItem.Model.ID,
223+ Provider: string(selectedItem.Provider.ID),
224+ ReasoningEffort: model.DefaultReasoningEffort,
225+ MaxTokens: model.DefaultMaxTokens,
226+ }
227+
228+ err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel)
229+ if err != nil {
230+ return util.ReportError(err)
231+ }
232+
233+ // Now lets automatically setup the small model
234+ knownProvider, err := s.getProvider(selectedItem.Provider.ID)
235+ if err != nil {
236+ return util.ReportError(err)
237+ }
238+ if knownProvider == nil {
239+ // for local provider we just use the same model
240+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
241+ if err != nil {
242+ return util.ReportError(err)
243+ }
244+ } else {
245+ smallModel := knownProvider.DefaultSmallModelID
246+ model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel)
247+ // should never happen
248+ if model == nil {
249+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel)
250+ if err != nil {
251+ return util.ReportError(err)
252+ }
253+ return nil
254+ }
255+ smallSelectedModel := config.SelectedModel{
256+ Model: smallModel,
257+ Provider: string(selectedItem.Provider.ID),
258+ ReasoningEffort: model.DefaultReasoningEffort,
259+ MaxTokens: model.DefaultMaxTokens,
260+ }
261+ err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel)
262+ if err != nil {
263+ return util.ReportError(err)
264+ }
265+ }
266+ return nil
267+}
268+
269+func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) {
270+ providers, err := config.Providers()
271+ if err != nil {
272+ return nil, err
273+ }
274+ for _, p := range providers {
275+ if p.ID == providerID {
276+ return &p, nil
277+ }
278+ }
279+ return nil, nil
280+}
281+
282+func (s *splashCmp) isProviderConfigured(providerID string) bool {
283+ cfg := config.Get()
284+ if _, ok := cfg.Providers[providerID]; ok {
285+ return true
286+ }
287+ return false
288+}
289+
119// View implements SplashPage.
290// View implements SplashPage.
120func (s *splashCmp) View() tea.View {
291func (s *splashCmp) View() tea.View {
121 t := styles.CurrentTheme()
292 t := styles.CurrentTheme()
@@ -141,6 +312,56 @@ func (s *splashCmp) View() tea.View {
141 s.logoRendered,
312 s.logoRendered,
142 modelSelector,
313 modelSelector,
143 )
314 )
315+ case SplashScreenStateInitialize:
316+ t := styles.CurrentTheme()
317+
318+ titleStyle := t.S().Base.Foreground(t.FgBase)
319+ bodyStyle := t.S().Base.Foreground(t.FgMuted)
320+ shortcutStyle := t.S().Base.Foreground(t.Success)
321+
322+ initText := lipgloss.JoinVertical(
323+ lipgloss.Left,
324+ titleStyle.Render("Would you like to initialize this project?"),
325+ "",
326+ bodyStyle.Render("When I initialize your codebase I examine the project and put the"),
327+ bodyStyle.Render("result into a CRUSH.md file which serves as general context."),
328+ "",
329+ bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."),
330+ "",
331+ bodyStyle.Render("Would you like to initialize now?"),
332+ )
333+
334+ yesButton := core.SelectableButton(core.ButtonOpts{
335+ Text: "Yep!",
336+ UnderlineIndex: 0,
337+ Selected: !s.selectedNo,
338+ })
339+
340+ noButton := core.SelectableButton(core.ButtonOpts{
341+ Text: "Nope",
342+ UnderlineIndex: 0,
343+ Selected: s.selectedNo,
344+ })
345+
346+ buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton)
347+
348+ remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2)
349+
350+ initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render(
351+ lipgloss.JoinVertical(
352+ lipgloss.Left,
353+ initText,
354+ "",
355+ buttons,
356+ ),
357+ )
358+
359+ content = lipgloss.JoinVertical(
360+ lipgloss.Left,
361+ s.logoRendered,
362+ initContent,
363+ )
364+
144 default:
365 default:
145 // Show just the logo for other states
366 // Show just the logo for other states
146 content = s.logoRendered
367 content = s.logoRendered
@@ -192,6 +413,14 @@ func (s *splashCmp) Bindings() []key.Binding {
192 s.keyMap.Next,
413 s.keyMap.Next,
193 s.keyMap.Previous,
414 s.keyMap.Previous,
194 }
415 }
416+ } else if s.state == SplashScreenStateInitialize {
417+ return []key.Binding{
418+ s.keyMap.Select,
419+ s.keyMap.Yes,
420+ s.keyMap.No,
421+ s.keyMap.Tab,
422+ s.keyMap.LeftRight,
423+ }
195 }
424 }
196 return []key.Binding{}
425 return []key.Binding{}
197}
426}
internal/tui/components/dialogs/init/init.godeleted−214
@@ -1,214 +0,0 @@
1−package init
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7−
8− "github.com/charmbracelet/crush/internal/config"
9− cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat"
10− "github.com/charmbracelet/crush/internal/tui/components/core"
11− "github.com/charmbracelet/crush/internal/tui/components/dialogs"
12− "github.com/charmbracelet/crush/internal/tui/styles"
13− "github.com/charmbracelet/crush/internal/tui/util"
14−)
15−
16−const InitDialogID dialogs.DialogID = "init"
17−
18−// InitDialogCmp is a component that asks the user if they want to initialize the project.
19−type InitDialogCmp interface {
20− dialogs.DialogModel
21−}
22−
23−type initDialogCmp struct {
24− wWidth, wHeight int
25− width, height int
26− selected int
27− keyMap KeyMap
28−}
29−
30−// NewInitDialogCmp creates a new InitDialogCmp.
31−func NewInitDialogCmp() InitDialogCmp {
32− return &initDialogCmp{
33− selected: 0,
34− keyMap: DefaultKeyMap(),
35− }
36−}
37−
38−// Init implements tea.Model.
39−func (m *initDialogCmp) Init() tea.Cmd {
40− return nil
41−}
42−
43−// Update implements tea.Model.
44−func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
45− switch msg := msg.(type) {
46− case tea.WindowSizeMsg:
47− m.wWidth = msg.Width
48− m.wHeight = msg.Height
49− cmd := m.SetSize()
50− return m, cmd
51− case tea.KeyPressMsg:
52− switch {
53− case key.Matches(msg, m.keyMap.Close):
54− return m, tea.Batch(
55− util.CmdHandler(dialogs.CloseDialogMsg{}),
56− m.handleInitialization(false),
57− )
58− case key.Matches(msg, m.keyMap.ChangeSelection):
59− m.selected = (m.selected + 1) % 2
60− return m, nil
61− case key.Matches(msg, m.keyMap.Select):
62− return m, tea.Batch(
63− util.CmdHandler(dialogs.CloseDialogMsg{}),
64− m.handleInitialization(m.selected == 0),
65− )
66− case key.Matches(msg, m.keyMap.Y):
67− return m, tea.Batch(
68− util.CmdHandler(dialogs.CloseDialogMsg{}),
69− m.handleInitialization(true),
70− )
71− case key.Matches(msg, m.keyMap.N):
72− return m, tea.Batch(
73− util.CmdHandler(dialogs.CloseDialogMsg{}),
74− m.handleInitialization(false),
75− )
76− }
77− }
78− return m, nil
79−}
80−
81−func (m *initDialogCmp) renderButtons() string {
82− t := styles.CurrentTheme()
83− baseStyle := t.S().Base
84−
85− buttons := []core.ButtonOpts{
86− {
87− Text: "Yes",
88− UnderlineIndex: 0, // "Y"
89− Selected: m.selected == 0,
90− },
91− {
92− Text: "No",
93− UnderlineIndex: 0, // "N"
94− Selected: m.selected == 1,
95− },
96− }
97−
98− content := core.SelectableButtons(buttons, " ")
99−
100− return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content)
101−}
102−
103−func (m *initDialogCmp) renderContent() string {
104− t := styles.CurrentTheme()
105− baseStyle := t.S().Base
106−
107− explanation := t.S().Text.
108− Width(m.width - 4).
109− Render("Initialization generates a new CRUSH.md file that contains information about your codebase, this file serves as memory for each project, you can freely add to it to help the agents be better at their job.")
110−
111− question := t.S().Text.
112− Width(m.width - 4).
113− Render("Would you like to initialize this project?")
114−
115− return baseStyle.Render(lipgloss.JoinVertical(
116− lipgloss.Left,
117− explanation,
118− "",
119− question,
120− ))
121−}
122−
123−func (m *initDialogCmp) render() string {
124− t := styles.CurrentTheme()
125− baseStyle := t.S().Base
126− title := core.Title("Initialize Project", m.width-4)
127−
128− content := m.renderContent()
129− buttons := m.renderButtons()
130−
131− dialogContent := lipgloss.JoinVertical(
132− lipgloss.Top,
133− title,
134− "",
135− content,
136− "",
137− buttons,
138− "",
139− )
140−
141− return baseStyle.
142− Padding(0, 1).
143− Border(lipgloss.RoundedBorder()).
144− BorderForeground(t.BorderFocus).
145− Width(m.width).
146− Render(dialogContent)
147−}
148−
149−// View implements tea.Model.
150−func (m *initDialogCmp) View() tea.View {
151− return tea.NewView(m.render())
152−}
153−
154−// SetSize sets the size of the component.
155−func (m *initDialogCmp) SetSize() tea.Cmd {
156− m.width = min(90, m.wWidth)
157− m.height = min(15, m.wHeight)
158− return nil
159−}
160−
161−// ID implements DialogModel.
162−func (m *initDialogCmp) ID() dialogs.DialogID {
163− return InitDialogID
164−}
165−
166−// Position implements DialogModel.
167−func (m *initDialogCmp) Position() (int, int) {
168− row := (m.wHeight / 2) - (m.height / 2)
169− col := (m.wWidth / 2) - (m.width / 2)
170− return row, col
171−}
172−
173−// handleInitialization handles the initialization logic when the dialog is closed.
174−func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd {
175− if initialize {
176− // Run the initialization command
177− prompt := `Please analyze this codebase and create a CRUSH.md file containing:
178−1. Build/lint/test commands - especially for running a single test
179−2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
180−
181−The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 20 lines long.
182−If there's already a CRUSH.md, improve it.
183−If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
184−Add the .crush directory to the .gitignore file if it's not already there.`
185−
186− // Mark the project as initialized
187− if err := config.MarkProjectInitialized(); err != nil {
188− return util.ReportError(err)
189− }
190−
191− return tea.Sequence(
192− util.CmdHandler(cmpChat.SessionClearedMsg{}),
193− util.CmdHandler(cmpChat.SendMsg{
194− Text: prompt,
195− }),
196− )
197− } else {
198− // Mark the project as initialized without running the command
199− if err := config.MarkProjectInitialized(); err != nil {
200− return util.ReportError(err)
201− }
202− }
203− return nil
204−}
205−
206−// CloseInitDialogMsg is a message that is sent when the init dialog is closed.
207−type CloseInitDialogMsg struct {
208− Initialize bool
209−}
210−
211−// ShowInitDialogMsg is a message that is sent to show the init dialog.
212−type ShowInitDialogMsg struct {
213− Show bool
214−}
internal/tui/components/dialogs/init/keys.godeleted−69
@@ -1,69 +0,0 @@
1−package init
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5−)
6−
7−type KeyMap struct {
8− ChangeSelection,
9− Select,
10− Y,
11− N,
12− Close key.Binding
13−}
14−
15−func DefaultKeyMap() KeyMap {
16− return KeyMap{
17− ChangeSelection: key.NewBinding(
18− key.WithKeys("tab", "left", "right", "h", "l"),
19− key.WithHelp("tab/←/→", "toggle selection"),
20− ),
21− Select: key.NewBinding(
22− key.WithKeys("enter"),
23− key.WithHelp("enter", "confirm"),
24− ),
25− Y: key.NewBinding(
26− key.WithKeys("y"),
27− key.WithHelp("y", "yes"),
28− ),
29− N: key.NewBinding(
30− key.WithKeys("n"),
31− key.WithHelp("n", "no"),
32− ),
33− Close: key.NewBinding(
34− key.WithKeys("esc"),
35− key.WithHelp("esc", "cancel"),
36− ),
37− }
38−}
39−
40−// KeyBindings implements layout.KeyMapProvider
41−func (k KeyMap) KeyBindings() []key.Binding {
42− return []key.Binding{
43− k.ChangeSelection,
44− k.Select,
45− k.Y,
46− k.N,
47− k.Close,
48− }
49−}
50−
51−// FullHelp implements help.KeyMap.
52−func (k KeyMap) FullHelp() [][]key.Binding {
53− m := [][]key.Binding{}
54− slice := k.KeyBindings()
55− for i := 0; i < len(slice); i += 4 {
56− end := min(i+4, len(slice))
57− m = append(m, slice[i:end])
58− }
59− return m
60−}
61−
62−// ShortHelp implements help.KeyMap.
63−func (k KeyMap) ShortHelp() []key.Binding {
64− return []key.Binding{
65− k.ChangeSelection,
66− k.Select,
67− k.Close,
68− }
69−}
internal/tui/components/dialogs/models/list.go+16−7
@@ -18,6 +18,7 @@ import (
18type ModelListComponent struct {
18type ModelListComponent struct {
19 list list.ListModel
19 list list.ListModel
20 modelType int
20 modelType int
21+ providers []provider.Provider
21}
22}
22
23
23func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
24func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent {
@@ -36,7 +37,16 @@ func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputP
36}
37}
37
38
38func (m *ModelListComponent) Init() tea.Cmd {
39func (m *ModelListComponent) Init() tea.Cmd {
40+ var cmds []tea.Cmd
41+ if len(m.providers) == 0 {
42+ providers, err := config.Providers()
43+ m.providers = providers
44+ if err != nil {
45+ cmds = append(cmds, util.ReportError(err))
46+ }
47+ }
39− return tea.Batch(m.list.Init(), m.SetModelType(m.modelType))
48+ cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType))
49+ return tea.Batch(cmds...)
40}
50}
41
51
42func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
52func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) {
@@ -65,11 +75,6 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
65 t := styles.CurrentTheme()
75 t := styles.CurrentTheme()
66 m.modelType = modelType
76 m.modelType = modelType
67
77
68− providers, err := config.Providers()
69− if err != nil {
70− return util.ReportError(err)
71− }
72−
73 modelItems := []util.Model{}
78 modelItems := []util.Model{}
74 selectIndex := 0
79 selectIndex := 0
75
80
@@ -144,7 +149,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd {
144 }
149 }
145
150
146 // Then add the known providers from the predefined list
151 // Then add the known providers from the predefined list
147− for _, provider := range providers {
152+ for _, provider := range m.providers {
148 // Skip if we already added this provider as an unknown provider
153 // Skip if we already added this provider as an unknown provider
149 if addedProviders[string(provider.ID)] {
154 if addedProviders[string(provider.ID)] {
150 continue
155 continue
@@ -187,3 +192,7 @@ func (m *ModelListComponent) GetModelType() int {
187func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
192func (m *ModelListComponent) SetInputPlaceholder(placeholder string) {
188 m.list.SetFilterPlaceholder(placeholder)
193 m.list.SetFilterPlaceholder(placeholder)
189}
194}
195+
196+func (m *ModelListComponent) SetProviders(providers []provider.Provider) {
197+ m.providers = providers
198+}
internal/tui/page/chat/chat.go+18
@@ -220,6 +220,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
220 if cmd != nil {
220 if cmd != nil {
221 return p, cmd
221 return p, cmd
222 }
222 }
223+ case splash.OnboardingCompleteMsg:
224+ p.state = ChatStateNewMessage
225+ err := p.app.InitCoderAgent()
226+ if err != nil {
227+ return p, util.ReportError(err)
228+ }
229+ p.focusedPane = PanelTypeEditor
230+ return p, p.SetSize(p.width, p.height)
223 case tea.KeyPressMsg:
231 case tea.KeyPressMsg:
224 switch {
232 switch {
225 case key.Matches(msg, p.keyMap.NewSession):
233 case key.Matches(msg, p.keyMap.NewSession):
@@ -233,6 +241,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
233 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
241 return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name)
234 }
242 }
235 case key.Matches(msg, p.keyMap.Tab):
243 case key.Matches(msg, p.keyMap.Tab):
244+ if p.state == ChatStateOnboarding || p.state == ChatStateInitProject {
245+ u, cmd := p.splash.Update(msg)
246+ p.splash = u.(splash.Splash)
247+ return p, cmd
248+ }
236 p.changeFocus()
249 p.changeFocus()
237 return p, nil
250 return p, nil
238 case key.Matches(msg, p.keyMap.Cancel):
251 case key.Matches(msg, p.keyMap.Cancel):
@@ -416,6 +429,7 @@ func (p *chatPage) newSession() tea.Cmd {
416 // Cannot start a new session if we are not in the session state
429 // Cannot start a new session if we are not in the session state
417 return nil
430 return nil
418 }
431 }
432+
419 // blank session
433 // blank session
420 p.session = session.Session{}
434 p.session = session.Session{}
421 p.state = ChatStateNewMessage
435 p.state = ChatStateNewMessage
@@ -456,8 +470,12 @@ func (p *chatPage) changeFocus() {
456 switch p.focusedPane {
470 switch p.focusedPane {
457 case PanelTypeChat:
471 case PanelTypeChat:
458 p.focusedPane = PanelTypeEditor
472 p.focusedPane = PanelTypeEditor
473+ p.editor.Focus()
474+ p.chat.Blur()
459 case PanelTypeEditor:
475 case PanelTypeEditor:
460 p.focusedPane = PanelTypeChat
476 p.focusedPane = PanelTypeChat
477+ p.chat.Focus()
478+ p.editor.Blur()
461 }
479 }
462}
480}
463
481
internal/tui/tui.go+1−19
@@ -19,7 +19,6 @@ import (
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
19 "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
20 "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
21 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
22− initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
22 "github.com/charmbracelet/crush/internal/tui/components/dialogs/models"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
23 "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions"
25 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
24 "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit"
@@ -65,23 +64,6 @@ func (a appModel) Init() tea.Cmd {
65 cmd = a.status.Init()
64 cmd = a.status.Init()
66 cmds = append(cmds, cmd)
65 cmds = append(cmds, cmd)
67
66
68− // Check if we should show the init dialog
69− cmds = append(cmds, func() tea.Msg {
70− shouldShow, err := config.ProjectNeedsInitialization()
71− if err != nil {
72− return util.InfoMsg{
73− Type: util.InfoTypeError,
74− Msg: "Failed to check init status: " + err.Error(),
75− }
76− }
77− if shouldShow {
78− return dialogs.OpenDialogMsg{
79− Model: initDialog.NewInitDialogCmp(),
80− }
81− }
82− return nil
83− })
84−
85 return tea.Batch(cmds...)
67 return tea.Batch(cmds...)
86}
68}
87
69
@@ -156,7 +138,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
156
138
157 // Model Switch
139 // Model Switch
158 case models.ModelSelectedMsg:
140 case models.ModelSelectedMsg:
159− config.UpdatePreferredModel(msg.ModelType, msg.Model)
141+ config.Get().UpdatePreferredModel(msg.ModelType, msg.Model)
160
142
161 // Update the agent with the new model/provider configuration
143 // Update the agent with the new model/provider configuration
162 if err := a.app.UpdateAgentModel(); err != nil {
144 if err := a.app.UpdateAgentModel(); err != nil {