From eeeef60efe72afc5b37757f1cdd4b4799cba8017 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 8 Jul 2025 16:50:03 +0200 Subject: [PATCH] wip: init screen and simple flow --- internal/config/config.go | 17 +- internal/config/load.go | 61 +++-- .../tui/components/chat/messages/messages.go | 7 + internal/tui/components/chat/splash/keys.go | 22 +- internal/tui/components/chat/splash/splash.go | 237 +++++++++++++++++- internal/tui/components/dialogs/init/init.go | 214 ---------------- internal/tui/components/dialogs/init/keys.go | 69 ----- .../tui/components/dialogs/models/list.go | 23 +- internal/tui/page/chat/chat.go | 18 ++ internal/tui/tui.go | 20 +- 10 files changed, 339 insertions(+), 349 deletions(-) delete mode 100644 internal/tui/components/dialogs/init/init.go delete mode 100644 internal/tui/components/dialogs/init/keys.go diff --git a/internal/config/config.go b/internal/config/config.go index 4d0012b66f6caab7ade4f205803c2443870360d9..bbf619e96ae8f6a9d96c8bcb29cbb92c717326b4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -208,7 +208,8 @@ type Config struct { // TODO: most likely remove this concept when I come back to it Agents map[string]Agent `json:"-"` // TODO: find a better way to do this this should probably not be part of the config - resolver VariableResolver + resolver VariableResolver + dataConfigDir string `json:"-"` } func (c *Config) WorkingDir() string { @@ -291,17 +292,17 @@ func (c *Config) Resolve(key string) (string, error) { return c.resolver.ResolveValue(key) } -// TODO: maybe handle this better -func UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { - cfg := Get() - cfg.Models[modelType] = model +func (c *Config) UpdatePreferredModel(modelType SelectedModelType, model SelectedModel) error { + c.Models[modelType] = model + if err := c.SetConfigField(fmt.Sprintf("models.%s", modelType), model); err != nil { + return fmt.Errorf("failed to update preferred model: %w", err) + } return nil } func (c *Config) SetConfigField(key string, value any) error { - configPath := GlobalConfigData() // read the data - data, err := os.ReadFile(configPath) + data, err := os.ReadFile(c.dataConfigDir) if err != nil { if os.IsNotExist(err) { data = []byte("{}") @@ -314,7 +315,7 @@ func (c *Config) SetConfigField(key string, value any) error { if err != nil { return fmt.Errorf("failed to set config field %s: %w", key, err) } - if err := os.WriteFile(configPath, []byte(newValue), 0o644); err != nil { + if err := os.WriteFile(c.dataConfigDir, []byte(newValue), 0o644); err != nil { return fmt.Errorf("failed to write config file: %w", err) } return nil diff --git a/internal/config/load.go b/internal/config/load.go index 1864a36c95bd6adcf3959f34cdf3958201312a94..96006c4415237cf802c10b08ab401e42bdd73fc6 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -46,6 +46,8 @@ func Load(workingDir string, debug bool) (*Config, error) { return nil, fmt.Errorf("failed to load config from paths %v: %w", configPaths, err) } + cfg.dataConfigDir = GlobalConfigData() + cfg.setDefaults(workingDir) if debug { @@ -367,10 +369,11 @@ func (cfg *Config) defaultModelSelection(knownProviders []provider.Provider) (la } func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) error { - large, small, err := cfg.defaultModelSelection(knownProviders) + defaultLarge, defaultSmall, err := cfg.defaultModelSelection(knownProviders) if err != nil { return fmt.Errorf("failed to select default models: %w", err) } + large, small := defaultLarge, defaultSmall largeModelSelected, largeModelConfigured := cfg.Models[SelectedModelTypeLarge] if largeModelConfigured { @@ -381,18 +384,26 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e large.Provider = largeModelSelected.Provider } model := cfg.GetModel(large.Provider, large.Model) + slog.Info("Configuring selected large model", "provider", large.Provider, "model", large.Model) + slog.Info("MOdel configured", "model", model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if largeModelSelected.MaxTokens > 0 { - large.MaxTokens = largeModelSelected.MaxTokens + large = defaultLarge + // override the model type to large + err := cfg.UpdatePreferredModel(SelectedModelTypeLarge, large) + if err != nil { + return fmt.Errorf("failed to update preferred large model: %w", err) + } } else { - large.MaxTokens = model.DefaultMaxTokens - } - if largeModelSelected.ReasoningEffort != "" { - large.ReasoningEffort = largeModelSelected.ReasoningEffort + if largeModelSelected.MaxTokens > 0 { + large.MaxTokens = largeModelSelected.MaxTokens + } else { + large.MaxTokens = model.DefaultMaxTokens + } + if largeModelSelected.ReasoningEffort != "" { + large.ReasoningEffort = largeModelSelected.ReasoningEffort + } + large.Think = largeModelSelected.Think } - large.Think = largeModelSelected.Think } smallModelSelected, smallModelConfigured := cfg.Models[SelectedModelTypeSmall] if smallModelConfigured { @@ -405,25 +416,21 @@ func (cfg *Config) configureSelectedModels(knownProviders []provider.Provider) e model := cfg.GetModel(small.Provider, small.Model) if model == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - if smallModelSelected.MaxTokens > 0 { - small.MaxTokens = smallModelSelected.MaxTokens + small = defaultSmall + // override the model type to small + err := cfg.UpdatePreferredModel(SelectedModelTypeSmall, small) + if err != nil { + return fmt.Errorf("failed to update preferred small model: %w", err) + } } else { - small.MaxTokens = model.DefaultMaxTokens + if smallModelSelected.MaxTokens > 0 { + small.MaxTokens = smallModelSelected.MaxTokens + } else { + small.MaxTokens = model.DefaultMaxTokens + } + small.ReasoningEffort = smallModelSelected.ReasoningEffort + small.Think = smallModelSelected.Think } - small.ReasoningEffort = smallModelSelected.ReasoningEffort - small.Think = smallModelSelected.Think - } - - // validate the selected models - largeModel := cfg.GetModel(large.Provider, large.Model) - if largeModel == nil { - return fmt.Errorf("large model %s not found for provider %s", large.Model, large.Provider) - } - smallModel := cfg.GetModel(small.Provider, small.Model) - if smallModel == nil { - return fmt.Errorf("small model %s not found for provider %s", small.Model, small.Provider) } cfg.Models[SelectedModelTypeLarge] = large cfg.Models[SelectedModelTypeSmall] = small diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 770b0729fd27a6d110605b05d6f66fae56981716..16b599ccda81c0ae77afc5a1e176845da7bebcab 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/internal/tui/components/chat/messages/messages.go @@ -10,6 +10,7 @@ import ( "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" "github.com/charmbracelet/crush/internal/message" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/components/core" @@ -296,6 +297,12 @@ func (m *assistantSectionModel) View() tea.View { infoMsg := t.S().Subtle.Render(duration.String()) icon := t.S().Subtle.Render(styles.ModelIcon) model := config.Get().GetModel(m.message.Provider, m.message.Model) + if model == nil { + // This means the model is not configured anymore + model = &provider.Model{ + Name: "Unknown Model", + } + } modelFormatted := t.S().Muted.Render(model.Name) assistant := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg) return tea.NewView( diff --git a/internal/tui/components/chat/splash/keys.go b/internal/tui/components/chat/splash/keys.go index 2a90441da52924f49f187c88744f9ea01c80e745..90b1954d9b3aa93f01bcafe9001276cc941748a6 100644 --- a/internal/tui/components/chat/splash/keys.go +++ b/internal/tui/components/chat/splash/keys.go @@ -7,7 +7,11 @@ import ( type KeyMap struct { Select, Next, - Previous key.Binding + Previous, + Yes, + No, + Tab, + LeftRight key.Binding } func DefaultKeyMap() KeyMap { @@ -24,5 +28,21 @@ func DefaultKeyMap() KeyMap { key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ), + Yes: key.NewBinding( + key.WithKeys("y"), + key.WithHelp("y", "yes"), + ), + No: key.NewBinding( + key.WithKeys("n"), + key.WithHelp("n", "no"), + ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch"), + ), + LeftRight: key.NewBinding( + key.WithKeys("left", "right"), + key.WithHelp("←/→", "switch"), + ), } } diff --git a/internal/tui/components/chat/splash/splash.go b/internal/tui/components/chat/splash/splash.go index 50912b283b05b6e2479fa82793e16e726a1cac8b..d06b006e60504c4517e8ef34a36eff9689ee1591 100644 --- a/internal/tui/components/chat/splash/splash.go +++ b/internal/tui/components/chat/splash/splash.go @@ -1,9 +1,16 @@ package splash import ( + "fmt" + "slices" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/fur/provider" + "github.com/charmbracelet/crush/internal/tui/components/chat" + "github.com/charmbracelet/crush/internal/tui/components/completions" + "github.com/charmbracelet/crush/internal/tui/components/core" "github.com/charmbracelet/crush/internal/tui/components/core/layout" "github.com/charmbracelet/crush/internal/tui/components/core/list" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" @@ -43,6 +50,7 @@ type splashCmp struct { state SplashScreenState modelList *models.ModelListComponent cursorRow, cursorCol int + selectedNo bool // true if "No" button is selected in initialize state } func New() Splash { @@ -67,6 +75,7 @@ func New() Splash { state: SplashScreenStateOnboarding, logoRendered: "", modelList: modelList, + selectedNo: false, } } @@ -83,8 +92,28 @@ func (s *splashCmp) Init() tea.Cmd { } else { s.state = SplashScreenStateReady } + } else { + providers, err := config.Providers() + if err != nil { + return util.ReportError(err) + } + filteredProviders := []provider.Provider{} + simpleProviders := []string{ + "anthropic", + "openai", + "gemini", + "xai", + "openrouter", + } + for _, p := range providers { + if slices.Contains(simpleProviders, string(p.ID)) { + filteredProviders = append(filteredProviders, p) + } + } + s.modelList.SetProviders(filteredProviders) + return s.modelList.Init() } - return s.modelList.Init() + return nil } // SetSize implements SplashPage. @@ -107,15 +136,157 @@ func (s *splashCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return s, s.SetSize(msg.Width, msg.Height) case tea.KeyPressMsg: switch { + case key.Matches(msg, s.keyMap.Select): + if s.state == SplashScreenStateOnboarding { + modelInx := s.modelList.SelectedIndex() + items := s.modelList.Items() + selectedItem := items[modelInx].(completions.CompletionItem).Value().(models.ModelOption) + if s.isProviderConfigured(string(selectedItem.Provider.ID)) { + cmd := s.setPreferredModel(selectedItem) + s.state = SplashScreenStateReady + if b, err := config.ProjectNeedsInitialization(); err != nil { + return s, tea.Batch(cmd, util.ReportError(err)) + } else if b { + s.state = SplashScreenStateInitialize + return s, cmd + } else { + s.state = SplashScreenStateReady + return s, tea.Batch(cmd, util.CmdHandler(OnboardingCompleteMsg{})) + } + } + } else if s.state == SplashScreenStateInitialize { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.Tab, s.keyMap.LeftRight): + if s.state == SplashScreenStateInitialize { + s.selectedNo = !s.selectedNo + return s, nil + } + case key.Matches(msg, s.keyMap.Yes): + if s.state == SplashScreenStateInitialize { + return s, s.initializeProject() + } + case key.Matches(msg, s.keyMap.No): + if s.state == SplashScreenStateInitialize { + s.state = SplashScreenStateReady + return s, util.CmdHandler(OnboardingCompleteMsg{}) + } default: - u, cmd := s.modelList.Update(msg) - s.modelList = u - return s, cmd + if s.state == SplashScreenStateOnboarding { + u, cmd := s.modelList.Update(msg) + s.modelList = u + return s, cmd + } } } return s, nil } +func (s *splashCmp) initializeProject() tea.Cmd { + s.state = SplashScreenStateReady + prompt := `Please analyze this codebase and create a CRUSH.md file containing: +1. Build/lint/test commands - especially for running a single test +2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. + +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. +If there's already a CRUSH.md, improve it. +If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. +Add the .crush directory to the .gitignore file if it's not already there.` + + // Mark the project as initialized + if err := config.MarkProjectInitialized(); err != nil { + return util.ReportError(err) + } + var cmds []tea.Cmd + + cmds = append(cmds, util.CmdHandler(OnboardingCompleteMsg{})) + if !s.selectedNo { + cmds = append(cmds, + util.CmdHandler(chat.SessionClearedMsg{}), + util.CmdHandler(chat.SendMsg{ + Text: prompt, + }), + ) + } + return tea.Sequence(cmds...) +} + +func (s *splashCmp) setPreferredModel(selectedItem models.ModelOption) tea.Cmd { + cfg := config.Get() + model := cfg.GetModel(string(selectedItem.Provider.ID), selectedItem.Model.ID) + if model == nil { + return util.ReportError(fmt.Errorf("model %s not found for provider %s", selectedItem.Model.ID, selectedItem.Provider.ID)) + } + + selectedModel := config.SelectedModel{ + Model: selectedItem.Model.ID, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + + err := cfg.UpdatePreferredModel(config.SelectedModelTypeLarge, selectedModel) + if err != nil { + return util.ReportError(err) + } + + // Now lets automatically setup the small model + knownProvider, err := s.getProvider(selectedItem.Provider.ID) + if err != nil { + return util.ReportError(err) + } + if knownProvider == nil { + // for local provider we just use the same model + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + } else { + smallModel := knownProvider.DefaultSmallModelID + model := cfg.GetModel(string(selectedItem.Provider.ID), smallModel) + // should never happen + if model == nil { + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, selectedModel) + if err != nil { + return util.ReportError(err) + } + return nil + } + smallSelectedModel := config.SelectedModel{ + Model: smallModel, + Provider: string(selectedItem.Provider.ID), + ReasoningEffort: model.DefaultReasoningEffort, + MaxTokens: model.DefaultMaxTokens, + } + err = cfg.UpdatePreferredModel(config.SelectedModelTypeSmall, smallSelectedModel) + if err != nil { + return util.ReportError(err) + } + } + return nil +} + +func (s *splashCmp) getProvider(providerID provider.InferenceProvider) (*provider.Provider, error) { + providers, err := config.Providers() + if err != nil { + return nil, err + } + for _, p := range providers { + if p.ID == providerID { + return &p, nil + } + } + return nil, nil +} + +func (s *splashCmp) isProviderConfigured(providerID string) bool { + cfg := config.Get() + if _, ok := cfg.Providers[providerID]; ok { + return true + } + return false +} + // View implements SplashPage. func (s *splashCmp) View() tea.View { t := styles.CurrentTheme() @@ -141,6 +312,56 @@ func (s *splashCmp) View() tea.View { s.logoRendered, modelSelector, ) + case SplashScreenStateInitialize: + t := styles.CurrentTheme() + + titleStyle := t.S().Base.Foreground(t.FgBase) + bodyStyle := t.S().Base.Foreground(t.FgMuted) + shortcutStyle := t.S().Base.Foreground(t.Success) + + initText := lipgloss.JoinVertical( + lipgloss.Left, + titleStyle.Render("Would you like to initialize this project?"), + "", + bodyStyle.Render("When I initialize your codebase I examine the project and put the"), + bodyStyle.Render("result into a CRUSH.md file which serves as general context."), + "", + bodyStyle.Render("You can also initialize anytime via ")+shortcutStyle.Render("ctrl+p")+bodyStyle.Render("."), + "", + bodyStyle.Render("Would you like to initialize now?"), + ) + + yesButton := core.SelectableButton(core.ButtonOpts{ + Text: "Yep!", + UnderlineIndex: 0, + Selected: !s.selectedNo, + }) + + noButton := core.SelectableButton(core.ButtonOpts{ + Text: "Nope", + UnderlineIndex: 0, + Selected: s.selectedNo, + }) + + buttons := lipgloss.JoinHorizontal(lipgloss.Left, yesButton, " ", noButton) + + remainingHeight := s.height - lipgloss.Height(s.logoRendered) - (SplashScreenPaddingY * 2) + + initContent := t.S().Base.AlignVertical(lipgloss.Bottom).Height(remainingHeight).Render( + lipgloss.JoinVertical( + lipgloss.Left, + initText, + "", + buttons, + ), + ) + + content = lipgloss.JoinVertical( + lipgloss.Left, + s.logoRendered, + initContent, + ) + default: // Show just the logo for other states content = s.logoRendered @@ -192,6 +413,14 @@ func (s *splashCmp) Bindings() []key.Binding { s.keyMap.Next, s.keyMap.Previous, } + } else if s.state == SplashScreenStateInitialize { + return []key.Binding{ + s.keyMap.Select, + s.keyMap.Yes, + s.keyMap.No, + s.keyMap.Tab, + s.keyMap.LeftRight, + } } return []key.Binding{} } diff --git a/internal/tui/components/dialogs/init/init.go b/internal/tui/components/dialogs/init/init.go deleted file mode 100644 index 74d0dc0b3d9d4630b28c4b240fb17fbe611ba21f..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/init.go +++ /dev/null @@ -1,214 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - - "github.com/charmbracelet/crush/internal/config" - cmpChat "github.com/charmbracelet/crush/internal/tui/components/chat" - "github.com/charmbracelet/crush/internal/tui/components/core" - "github.com/charmbracelet/crush/internal/tui/components/dialogs" - "github.com/charmbracelet/crush/internal/tui/styles" - "github.com/charmbracelet/crush/internal/tui/util" -) - -const InitDialogID dialogs.DialogID = "init" - -// InitDialogCmp is a component that asks the user if they want to initialize the project. -type InitDialogCmp interface { - dialogs.DialogModel -} - -type initDialogCmp struct { - wWidth, wHeight int - width, height int - selected int - keyMap KeyMap -} - -// NewInitDialogCmp creates a new InitDialogCmp. -func NewInitDialogCmp() InitDialogCmp { - return &initDialogCmp{ - selected: 0, - keyMap: DefaultKeyMap(), - } -} - -// Init implements tea.Model. -func (m *initDialogCmp) Init() tea.Cmd { - return nil -} - -// Update implements tea.Model. -func (m *initDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.wWidth = msg.Width - m.wHeight = msg.Height - cmd := m.SetSize() - return m, cmd - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.keyMap.Close): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - case key.Matches(msg, m.keyMap.ChangeSelection): - m.selected = (m.selected + 1) % 2 - return m, nil - case key.Matches(msg, m.keyMap.Select): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(m.selected == 0), - ) - case key.Matches(msg, m.keyMap.Y): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(true), - ) - case key.Matches(msg, m.keyMap.N): - return m, tea.Batch( - util.CmdHandler(dialogs.CloseDialogMsg{}), - m.handleInitialization(false), - ) - } - } - return m, nil -} - -func (m *initDialogCmp) renderButtons() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - buttons := []core.ButtonOpts{ - { - Text: "Yes", - UnderlineIndex: 0, // "Y" - Selected: m.selected == 0, - }, - { - Text: "No", - UnderlineIndex: 0, // "N" - Selected: m.selected == 1, - }, - } - - content := core.SelectableButtons(buttons, " ") - - return baseStyle.AlignHorizontal(lipgloss.Right).Width(m.width - 4).Render(content) -} - -func (m *initDialogCmp) renderContent() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - - explanation := t.S().Text. - Width(m.width - 4). - 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.") - - question := t.S().Text. - Width(m.width - 4). - Render("Would you like to initialize this project?") - - return baseStyle.Render(lipgloss.JoinVertical( - lipgloss.Left, - explanation, - "", - question, - )) -} - -func (m *initDialogCmp) render() string { - t := styles.CurrentTheme() - baseStyle := t.S().Base - title := core.Title("Initialize Project", m.width-4) - - content := m.renderContent() - buttons := m.renderButtons() - - dialogContent := lipgloss.JoinVertical( - lipgloss.Top, - title, - "", - content, - "", - buttons, - "", - ) - - return baseStyle. - Padding(0, 1). - Border(lipgloss.RoundedBorder()). - BorderForeground(t.BorderFocus). - Width(m.width). - Render(dialogContent) -} - -// View implements tea.Model. -func (m *initDialogCmp) View() tea.View { - return tea.NewView(m.render()) -} - -// SetSize sets the size of the component. -func (m *initDialogCmp) SetSize() tea.Cmd { - m.width = min(90, m.wWidth) - m.height = min(15, m.wHeight) - return nil -} - -// ID implements DialogModel. -func (m *initDialogCmp) ID() dialogs.DialogID { - return InitDialogID -} - -// Position implements DialogModel. -func (m *initDialogCmp) Position() (int, int) { - row := (m.wHeight / 2) - (m.height / 2) - col := (m.wWidth / 2) - (m.width / 2) - return row, col -} - -// handleInitialization handles the initialization logic when the dialog is closed. -func (m *initDialogCmp) handleInitialization(initialize bool) tea.Cmd { - if initialize { - // Run the initialization command - prompt := `Please analyze this codebase and create a CRUSH.md file containing: -1. Build/lint/test commands - especially for running a single test -2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc. - -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. -If there's already a CRUSH.md, improve it. -If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them. -Add the .crush directory to the .gitignore file if it's not already there.` - - // Mark the project as initialized - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - - return tea.Sequence( - util.CmdHandler(cmpChat.SessionClearedMsg{}), - util.CmdHandler(cmpChat.SendMsg{ - Text: prompt, - }), - ) - } else { - // Mark the project as initialized without running the command - if err := config.MarkProjectInitialized(); err != nil { - return util.ReportError(err) - } - } - return nil -} - -// CloseInitDialogMsg is a message that is sent when the init dialog is closed. -type CloseInitDialogMsg struct { - Initialize bool -} - -// ShowInitDialogMsg is a message that is sent to show the init dialog. -type ShowInitDialogMsg struct { - Show bool -} diff --git a/internal/tui/components/dialogs/init/keys.go b/internal/tui/components/dialogs/init/keys.go deleted file mode 100644 index afd82d45ea8b47630c2d5ed1450419ae8d4b4c19..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialogs/init/keys.go +++ /dev/null @@ -1,69 +0,0 @@ -package init - -import ( - "github.com/charmbracelet/bubbles/v2/key" -) - -type KeyMap struct { - ChangeSelection, - Select, - Y, - N, - Close key.Binding -} - -func DefaultKeyMap() KeyMap { - return KeyMap{ - ChangeSelection: key.NewBinding( - key.WithKeys("tab", "left", "right", "h", "l"), - key.WithHelp("tab/←/→", "toggle selection"), - ), - Select: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "confirm"), - ), - Y: key.NewBinding( - key.WithKeys("y"), - key.WithHelp("y", "yes"), - ), - N: key.NewBinding( - key.WithKeys("n"), - key.WithHelp("n", "no"), - ), - Close: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), - } -} - -// KeyBindings implements layout.KeyMapProvider -func (k KeyMap) KeyBindings() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Y, - k.N, - k.Close, - } -} - -// FullHelp implements help.KeyMap. -func (k KeyMap) FullHelp() [][]key.Binding { - m := [][]key.Binding{} - slice := k.KeyBindings() - for i := 0; i < len(slice); i += 4 { - end := min(i+4, len(slice)) - m = append(m, slice[i:end]) - } - return m -} - -// ShortHelp implements help.KeyMap. -func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{ - k.ChangeSelection, - k.Select, - k.Close, - } -} diff --git a/internal/tui/components/dialogs/models/list.go b/internal/tui/components/dialogs/models/list.go index bbb23300ae218830cff76daaa8418ab1a75ff15e..4a4eeb300dfb97c1db2145fcec24a81cda2fd124 100644 --- a/internal/tui/components/dialogs/models/list.go +++ b/internal/tui/components/dialogs/models/list.go @@ -18,6 +18,7 @@ import ( type ModelListComponent struct { list list.ListModel modelType int + providers []provider.Provider } func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputPlaceholder string) *ModelListComponent { @@ -36,7 +37,16 @@ func NewModelListComponent(keyMap list.KeyMap, inputStyle lipgloss.Style, inputP } func (m *ModelListComponent) Init() tea.Cmd { - return tea.Batch(m.list.Init(), m.SetModelType(m.modelType)) + var cmds []tea.Cmd + if len(m.providers) == 0 { + providers, err := config.Providers() + m.providers = providers + if err != nil { + cmds = append(cmds, util.ReportError(err)) + } + } + cmds = append(cmds, m.list.Init(), m.SetModelType(m.modelType)) + return tea.Batch(cmds...) } func (m *ModelListComponent) Update(msg tea.Msg) (*ModelListComponent, tea.Cmd) { @@ -65,11 +75,6 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { t := styles.CurrentTheme() m.modelType = modelType - providers, err := config.Providers() - if err != nil { - return util.ReportError(err) - } - modelItems := []util.Model{} selectIndex := 0 @@ -144,7 +149,7 @@ func (m *ModelListComponent) SetModelType(modelType int) tea.Cmd { } // Then add the known providers from the predefined list - for _, provider := range providers { + for _, provider := range m.providers { // Skip if we already added this provider as an unknown provider if addedProviders[string(provider.ID)] { continue @@ -187,3 +192,7 @@ func (m *ModelListComponent) GetModelType() int { func (m *ModelListComponent) SetInputPlaceholder(placeholder string) { m.list.SetFilterPlaceholder(placeholder) } + +func (m *ModelListComponent) SetProviders(providers []provider.Provider) { + m.providers = providers +} diff --git a/internal/tui/page/chat/chat.go b/internal/tui/page/chat/chat.go index fc529bba8b2be2bbbab1e5c7dd5668bcf7923ab8..1614ed730fba6986ed513a6592bcbfc533b4ead5 100644 --- a/internal/tui/page/chat/chat.go +++ b/internal/tui/page/chat/chat.go @@ -220,6 +220,14 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if cmd != nil { return p, cmd } + case splash.OnboardingCompleteMsg: + p.state = ChatStateNewMessage + err := p.app.InitCoderAgent() + if err != nil { + return p, util.ReportError(err) + } + p.focusedPane = PanelTypeEditor + return p, p.SetSize(p.width, p.height) case tea.KeyPressMsg: switch { case key.Matches(msg, p.keyMap.NewSession): @@ -233,6 +241,11 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, util.ReportWarn("File attachments are not supported by the current model: " + model.Name) } case key.Matches(msg, p.keyMap.Tab): + if p.state == ChatStateOnboarding || p.state == ChatStateInitProject { + u, cmd := p.splash.Update(msg) + p.splash = u.(splash.Splash) + return p, cmd + } p.changeFocus() return p, nil case key.Matches(msg, p.keyMap.Cancel): @@ -416,6 +429,7 @@ func (p *chatPage) newSession() tea.Cmd { // Cannot start a new session if we are not in the session state return nil } + // blank session p.session = session.Session{} p.state = ChatStateNewMessage @@ -456,8 +470,12 @@ func (p *chatPage) changeFocus() { switch p.focusedPane { case PanelTypeChat: p.focusedPane = PanelTypeEditor + p.editor.Focus() + p.chat.Blur() case PanelTypeEditor: p.focusedPane = PanelTypeChat + p.chat.Focus() + p.editor.Blur() } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 4ebf9f7edcf0d35cda2b7c2c6a3fa6d20cdd97b3..5aa89aa73d3fb39a556c5f407ba73144a560ea6c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -19,7 +19,6 @@ import ( "github.com/charmbracelet/crush/internal/tui/components/dialogs/commands" "github.com/charmbracelet/crush/internal/tui/components/dialogs/compact" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" - initDialog "github.com/charmbracelet/crush/internal/tui/components/dialogs/init" "github.com/charmbracelet/crush/internal/tui/components/dialogs/models" "github.com/charmbracelet/crush/internal/tui/components/dialogs/permissions" "github.com/charmbracelet/crush/internal/tui/components/dialogs/quit" @@ -65,23 +64,6 @@ func (a appModel) Init() tea.Cmd { cmd = a.status.Init() cmds = append(cmds, cmd) - // Check if we should show the init dialog - cmds = append(cmds, func() tea.Msg { - shouldShow, err := config.ProjectNeedsInitialization() - if err != nil { - return util.InfoMsg{ - Type: util.InfoTypeError, - Msg: "Failed to check init status: " + err.Error(), - } - } - if shouldShow { - return dialogs.OpenDialogMsg{ - Model: initDialog.NewInitDialogCmp(), - } - } - return nil - }) - return tea.Batch(cmds...) } @@ -156,7 +138,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Model Switch case models.ModelSelectedMsg: - config.UpdatePreferredModel(msg.ModelType, msg.Model) + config.Get().UpdatePreferredModel(msg.ModelType, msg.Model) // Update the agent with the new model/provider configuration if err := a.app.UpdateAgentModel(); err != nil {