From db5c3eb3983e838eec277e959e42c6fa18f4272a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 25 Nov 2025 09:28:37 +0100 Subject: [PATCH] chore: initialize functionality & landing page --- internal/ui/common/elements.go | 121 ++++++++++++++++ internal/ui/model/keys.go | 5 + internal/ui/model/landing.go | 71 ++++++++++ internal/ui/model/lsp.go | 113 +++++++++++++++ internal/ui/model/mcp.go | 88 ++++++++++++ internal/ui/model/onboarding.go | 96 +++++++++++++ internal/ui/model/ui.go | 239 +++++++++++++++----------------- internal/ui/styles/styles.go | 26 +++- 8 files changed, 628 insertions(+), 131 deletions(-) create mode 100644 internal/ui/common/elements.go create mode 100644 internal/ui/model/landing.go create mode 100644 internal/ui/model/lsp.go create mode 100644 internal/ui/model/mcp.go create mode 100644 internal/ui/model/onboarding.go diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go new file mode 100644 index 0000000000000000000000000000000000000000..762fac8e12ebad622c4df4e16c4ebd12ad0f6613 --- /dev/null +++ b/internal/ui/common/elements.go @@ -0,0 +1,121 @@ +package common + +import ( + "cmp" + "fmt" + "image/color" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +func PrettyPath(t *styles.Styles, path string, width int) string { + formatted := home.Short(path) + return t.Muted.Width(width).Render(formatted) +} + +type ModelContextInfo struct { + ContextUsed int64 + ModelContext int64 + Cost float64 +} + +func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string { + modelIcon := t.Subtle.Render(styles.ModelIcon) + modelName = t.Base.Render(modelName) + modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName) + + parts := []string{ + modelInfo, + } + + if reasoningInfo != "" { + parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo)) + } + + if context != nil { + parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost)) + } + + return lipgloss.NewStyle().Width(width).Render( + lipgloss.JoinVertical(lipgloss.Left, parts...), + ) +} + +func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string { + var formattedTokens string + switch { + case tokens >= 1_000_000: + formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) + case tokens >= 1_000: + formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) + default: + formattedTokens = fmt.Sprintf("%d", tokens) + } + + if strings.HasSuffix(formattedTokens, ".0K") { + formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) + } + if strings.HasSuffix(formattedTokens, ".0M") { + formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) + } + + percentage := (float64(tokens) / float64(contextWindow)) * 100 + + formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost)) + + formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens)) + formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage))) + formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens) + if percentage > 80 { + formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens) + } + + return fmt.Sprintf("%s %s", formattedTokens, formattedCost) +} + +type StatusOpts struct { + Icon string // if empty no icon will be shown + Title string + TitleColor color.Color + Description string + DescriptionColor color.Color + ExtraContent string // additional content to append after the description +} + +func Status(t *styles.Styles, opts StatusOpts, width int) string { + icon := opts.Icon + title := opts.Title + description := opts.Description + + titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground()) + descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground()) + + title = t.Base.Foreground(titleColor).Render(title) + + if description != "" { + extraContentWidth := lipgloss.Width(opts.ExtraContent) + if extraContentWidth > 0 { + extraContentWidth += 1 + } + description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…") + description = t.Base.Foreground(descriptionColor).Render(description) + } + + content := []string{} + if icon != "" { + content = append(content, icon) + } + content = append(content, title) + if description != "" { + content = append(content, description) + } + if opts.ExtraContent != "" { + content = append(content, opts.ExtraContent) + } + + return strings.Join(content, " ") +} diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index cf5d2721372f5a7d49de85d6b2155d8dfb24e4af..4acf010512b64de707eb6716ba574c885604276d 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -20,6 +20,7 @@ type KeyMap struct { Initialize struct { Yes, No, + Enter, Switch key.Binding } @@ -117,6 +118,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("left", "right", "tab"), key.WithHelp("tab", "switch"), ) + km.Initialize.Enter = key.NewBinding( + key.WithKeys("enter"), + key.WithHelp("enter", "select"), + ) return km } diff --git a/internal/ui/model/landing.go b/internal/ui/model/landing.go new file mode 100644 index 0000000000000000000000000000000000000000..dda843250db29a19626cd7dae5e67990bd85c1ad --- /dev/null +++ b/internal/ui/model/landing.go @@ -0,0 +1,71 @@ +package model + +import ( + "cmp" + "fmt" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/catwalk/pkg/catwalk" + "github.com/charmbracelet/crush/internal/agent" + "github.com/charmbracelet/crush/internal/ui/common" + uv "github.com/charmbracelet/ultraviolet" + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +func (m *UI) selectedLargeModel() *agent.Model { + if m.com.App.AgentCoordinator != nil { + model := m.com.App.AgentCoordinator.Model() + return &model + } + return nil +} + +func (m *UI) landingView() string { + t := m.com.Styles + width := m.layout.main.Dx() + cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width) + + parts := []string{ + cwd, + } + + model := m.selectedLargeModel() + if model != nil && model.CatwalkCfg.CanReason { + reasoningInfo := "" + providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider) + if ok { + switch providerConfig.Type { + case catwalk.TypeAnthropic: + if model.ModelCfg.Think { + reasoningInfo = "Thinking On" + } else { + reasoningInfo = "Thinking Off" + } + default: + formatter := cases.Title(language.English, cases.NoLower) + reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort) + reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort)) + } + parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width)) + } + } + infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...) + + _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1)) + + mcpLspSectionWidth := min(30, (width-1)/2) + + lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy()) + + content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy() - 1). + PaddingTop(1). + Render( + lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content), + ) +} diff --git a/internal/ui/model/lsp.go b/internal/ui/model/lsp.go new file mode 100644 index 0000000000000000000000000000000000000000..220133b793da374920c7bd0fc2ee9e0aecd3b67b --- /dev/null +++ b/internal/ui/model/lsp.go @@ -0,0 +1,113 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/lsp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/powernap/pkg/lsp/protocol" +) + +type LSPInfo struct { + app.LSPClientInfo + Diagnostics map[protocol.DiagnosticSeverity]int +} + +func (m *UI) lspInfo(t *styles.Styles, width, height int) string { + var lsps []LSPInfo + + for _, state := range m.lspStates { + client, ok := m.com.App.LSPClients.Get(state.Name) + if !ok { + continue + } + lspErrs := map[protocol.DiagnosticSeverity]int{ + protocol.SeverityError: 0, + protocol.SeverityWarning: 0, + protocol.SeverityHint: 0, + protocol.SeverityInformation: 0, + } + + for _, diagnostics := range client.GetDiagnostics() { + for _, diagnostic := range diagnostics { + if severity, ok := lspErrs[diagnostic.Severity]; ok { + lspErrs[diagnostic.Severity] = severity + 1 + } + } + } + + lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs}) + } + title := t.Subtle.Render("LSPs") + list := t.Subtle.Render("None") + if len(lsps) > 0 { + height = max(0, height-2) // remove title and space + list = lspList(t, lsps, width, height) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string { + errs := []string{} + if diagnostics[protocol.SeverityError] > 0 { + errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError]))) + } + if diagnostics[protocol.SeverityWarning] > 0 { + errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning]))) + } + if diagnostics[protocol.SeverityHint] > 0 { + errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint]))) + } + if diagnostics[protocol.SeverityInformation] > 0 { + errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation]))) + } + return strings.Join(errs, " ") +} + +func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string { + var renderedLsps []string + for _, l := range lsps { + var icon string + title := l.Name + var description string + var diagnostics string + switch l.State { + case lsp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case lsp.StateReady: + icon = t.ItemOnlineIcon.String() + diagnostics = lspDiagnostics(t, l.Diagnostics) + case lsp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if l.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error())) + } + case lsp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("inactive") + default: + icon = t.ItemOfflineIcon.String() + } + renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: diagnostics, + }, width)) + } + + if len(renderedLsps) > height { + visibleItems := renderedLsps[:height-1] + remaining := len(renderedLsps) - (height - 1) + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...) +} diff --git a/internal/ui/model/mcp.go b/internal/ui/model/mcp.go new file mode 100644 index 0000000000000000000000000000000000000000..f4278a1e45afac2a7f8d12b495e1eb0be7cecfa5 --- /dev/null +++ b/internal/ui/model/mcp.go @@ -0,0 +1,88 @@ +package model + +import ( + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +type MCPInfo struct { + mcp.ClientInfo +} + +func (m *UI) mcpInfo(t *styles.Styles, width, height int) string { + var mcps []MCPInfo + + for _, state := range m.mcpStates { + mcps = append(mcps, MCPInfo{ClientInfo: state}) + } + + title := t.Subtle.Render("MCPs") + list := t.Subtle.Render("None") + if len(mcps) > 0 { + height = max(0, height-2) // remove title and space + list = mcpList(t, mcps, width, height) + } + + return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list)) +} + +func mcpCounts(t *styles.Styles, counts mcp.Counts) string { + parts := []string{} + if counts.Tools > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools))) + } + if counts.Prompts > 0 { + parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts))) + } + return strings.Join(parts, " ") +} + +func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string { + var renderedMcps []string + for _, m := range mcps { + var icon string + title := m.Name + var description string + var extraContent string + + switch m.State { + case mcp.StateStarting: + icon = t.ItemBusyIcon.String() + description = t.Subtle.Render("starting...") + case mcp.StateConnected: + icon = t.ItemOnlineIcon.String() + extraContent = mcpCounts(t, m.Counts) + case mcp.StateError: + icon = t.ItemErrorIcon.String() + description = t.Subtle.Render("error") + if m.Error != nil { + description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error())) + } + case mcp.StateDisabled: + icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String() + description = t.Subtle.Render("disabled") + default: + icon = t.ItemOfflineIcon.String() + } + + renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{ + Icon: icon, + Title: title, + Description: description, + ExtraContent: extraContent, + }, width)) + } + + if len(renderedMcps) > height { + visibleItems := renderedMcps[:height-1] + remaining := len(renderedMcps) - (height - 1) + visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining))) + return lipgloss.JoinVertical(lipgloss.Left, visibleItems...) + } + return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...) +} diff --git a/internal/ui/model/onboarding.go b/internal/ui/model/onboarding.go new file mode 100644 index 0000000000000000000000000000000000000000..2f50ae85d1086dd00674e87560b44a4ae2aad202 --- /dev/null +++ b/internal/ui/model/onboarding.go @@ -0,0 +1,96 @@ +package model + +import ( + "fmt" + "log/slog" + "strings" + + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/ui/common" +) + +func (m *UI) markProjectInitialized() tea.Msg { + // TODO: handle error so we show it in the tui footer + err := config.MarkProjectInitialized() + if err != nil { + slog.Error(err.Error()) + } + return nil +} + +func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch { + case key.Matches(msg, m.keyMap.Initialize.Enter): + if m.onboarding.yesInitializeSelected { + cmds = append(cmds, m.initializeProject()) + } else { + cmds = append(cmds, m.skipInitializeProject()) + } + case key.Matches(msg, m.keyMap.Initialize.Switch): + m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected + case key.Matches(msg, m.keyMap.Initialize.Yes): + cmds = append(cmds, m.initializeProject()) + case key.Matches(msg, m.keyMap.Initialize.No): + cmds = append(cmds, m.skipInitializeProject()) + } + return cmds +} + +func (m *UI) initializeProject() tea.Cmd { + // TODO: initialize the project + // for now we just go to the landing page + m.state = uiLanding + m.focus = uiFocusEditor + // TODO: actually send a message to the agent + return m.markProjectInitialized +} + +func (m *UI) skipInitializeProject() tea.Cmd { + // TODO: initialize the project + m.state = uiLanding + m.focus = uiFocusEditor + // mark the project as initialized + return m.markProjectInitialized +} + +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected}, + {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 7b53610ac9d0ff381154faa679867a279b2a62aa..cfa699b87f7c8d38d07410ff06b45e7958a3ac07 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,7 +1,6 @@ package model import ( - "fmt" "image" "math/rand" "os" @@ -13,8 +12,10 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/agent/tools/mcp" + "github.com/charmbracelet/crush/internal/app" "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/home" + "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" @@ -87,8 +88,16 @@ type UI struct { readyPlaceholder string workingPlaceholder string - // Initialize state - yesInitializeSelected bool + // onboarding state + onboarding struct { + yesInitializeSelected bool + } + + // lsp + lspStates map[string]app.LSPClientInfo + + // mcp + mcpStates map[string]mcp.ClientInfo } // New creates a new instance of the [UI] model. @@ -110,10 +119,11 @@ func New(com *common.Common) *UI { focus: uiFocusNone, state: uiConfigure, textarea: ta, - - // initialize - yesInitializeSelected: true, } + + // set onboarding state defaults + ui.onboarding.yesInitializeSelected = true + // If no provider is configured show the user the provider list if !com.Config().IsConfigured() { ui.state = uiConfigure @@ -129,6 +139,7 @@ func New(com *common.Common) *UI { ui.setEditorPrompt() ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder + ui.help.Styles = com.Styles.Help return ui } @@ -144,13 +155,16 @@ func (m *UI) Init() tea.Cmd { // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - hasDialogs := m.dialog.HasDialogs() switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } + case pubsub.Event[app.LSPEvent]: + m.lspStates = app.GetLSPStates() + case pubsub.Event[mcp.Event]: + m.mcpStates = mcp.GetStates() case tea.TerminalVersionMsg: termVersion := strings.ToLower(msg.Name) // Only enable progress bar for the following terminals. @@ -168,60 +182,67 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") } case tea.KeyPressMsg: - if hasDialogs { - m.updateDialogs(msg, &cmds) - } + cmds = append(cmds, m.handleKeyPressMsg(msg)...) } - if !hasDialogs { - // This branch only handles UI elements when there's no dialog shown. - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, m.keyMap.Tab): - if m.focus == uiFocusMain { - m.focus = uiFocusEditor - cmds = append(cmds, m.textarea.Focus()) - } else { - m.focus = uiFocusMain - m.textarea.Blur() - } - case key.Matches(msg, m.keyMap.Help): - m.help.ShowAll = !m.help.ShowAll - m.updateLayoutAndSize() - case key.Matches(msg, m.keyMap.Quit): - if !m.dialog.ContainsDialog(dialog.QuitDialogID) { - m.dialog.AddDialog(dialog.NewQuit(m.com)) - return m, nil - } - case key.Matches(msg, m.keyMap.Commands): - // TODO: Implement me - case key.Matches(msg, m.keyMap.Models): - // TODO: Implement me - case key.Matches(msg, m.keyMap.Sessions): - // TODO: Implement me - default: - m.updateFocused(msg, &cmds) - } + // This logic gets triggered on any message type, but should it? + switch m.focus { + case uiFocusMain: + case uiFocusEditor: + // Textarea placeholder logic + if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { + m.textarea.Placeholder = m.workingPlaceholder + } else { + m.textarea.Placeholder = m.readyPlaceholder + } + if m.com.App.Permissions.SkipRequests() { + m.textarea.Placeholder = "Yolo mode!" } + } - // This logic gets triggered on any message type, but should it? - switch m.focus { - case uiFocusMain: - case uiFocusEditor: - // Textarea placeholder logic - if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { - m.textarea.Placeholder = m.workingPlaceholder + return m, tea.Batch(cmds...) +} + +func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + if m.dialog.HasDialogs() { + return m.updateDialogs(msg) + } + + switch { + case key.Matches(msg, m.keyMap.Tab): + switch m.state { + case uiChat: + if m.focus == uiFocusMain { + m.focus = uiFocusEditor + cmds = append(cmds, m.textarea.Focus()) } else { - m.textarea.Placeholder = m.readyPlaceholder - } - if m.com.App.Permissions.SkipRequests() { - m.textarea.Placeholder = "Yolo mode!" + m.focus = uiFocusMain + m.textarea.Blur() } } + case key.Matches(msg, m.keyMap.Help): + m.help.ShowAll = !m.help.ShowAll + m.updateLayoutAndSize() + return cmds + case key.Matches(msg, m.keyMap.Quit): + if !m.dialog.ContainsDialog(dialog.QuitDialogID) { + m.dialog.AddDialog(dialog.NewQuit(m.com)) + return + } + return cmds + case key.Matches(msg, m.keyMap.Commands): + // TODO: Implement me + return cmds + case key.Matches(msg, m.keyMap.Models): + // TODO: Implement me + return cmds + case key.Matches(msg, m.keyMap.Sessions): + // TODO: Implement me + return cmds } - return m, tea.Batch(cmds...) + cmds = append(cmds, m.updateFocused(msg)...) + return cmds } // Draw implements [tea.Layer] and draws the UI model. @@ -253,12 +274,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { case uiLanding: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) - - mainView := lipgloss.NewStyle().Width(layout.main.Dx()). - Height(layout.main.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Landing Page ") - main := uv.NewStyledString(mainView) + main := uv.NewStyledString(m.landingView()) main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) @@ -378,9 +394,10 @@ func (m *UI) ShortHelp() []key.Binding { k.Quit, k.Help, ) - } else { - // we have a session } + // else { + // we have a session + // } // switch m.state { // case uiChat: @@ -400,7 +417,6 @@ func (m *UI) ShortHelp() []key.Binding { // ) // } // } - } return binds @@ -438,9 +454,10 @@ func (m *UI) FullHelp() [][]key.Binding { help, }, ) - } else { - // we have a session } + // else { + // we have a session + // } } // switch m.state { @@ -452,44 +469,48 @@ func (m *UI) FullHelp() [][]key.Binding { return binds } -// updateDialogs updates the dialog overlay with the given message and appends -// any resulting commands to the cmds slice. -func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { +// updateDialogs updates the dialog overlay with the given message and returns cmds +func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedDialog, cmd := m.dialog.Update(msg) m.dialog = updatedDialog - if cmd != nil { - *cmds = append(*cmds, cmd) - } + cmds = append(cmds, cmd) + return cmds } // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. -func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - switch m.focus { - case uiFocusMain: - m.updateChat(msg, cmds) - case uiFocusEditor: - switch { - case key.Matches(msg, m.keyMap.Editor.Newline): - m.textarea.InsertRune('\n') - } +func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) { + switch m.state { + case uiConfigure: + return cmds + case uiInitialize: + return append(cmds, m.updateInitializeView(msg)...) + case uiChat, uiLanding, uiChatCompact: + switch m.focus { + case uiFocusMain: + cmds = append(cmds, m.updateChat(msg)...) + case uiFocusEditor: + switch { + case key.Matches(msg, m.keyMap.Editor.Newline): + m.textarea.InsertRune('\n') + } - ta, cmd := m.textarea.Update(msg) - m.textarea = ta - if cmd != nil { - *cmds = append(*cmds, cmd) + ta, cmd := m.textarea.Update(msg) + m.textarea = ta + cmds = append(cmds, cmd) + return cmds } } + return cmds } // updateChat updates the chat model with the given message and appends any // resulting commands to the cmds slice. -func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { +func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) { updatedChat, cmd := m.chat.Update(msg) m.chat = updatedChat - if cmd != nil { - *cmds = append(*cmds, cmd) - } + cmds = append(cmds, cmd) + return cmds } // updateLayoutAndSize updates the layout and sizes of UI components. @@ -509,7 +530,6 @@ func (m *UI) updateSize() { m.renderHeader(false, m.layout.header.Dx()) case uiLanding: - // TODO: set the width and heigh of the chat component m.renderHeader(false, m.layout.header.Dx()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) @@ -557,7 +577,7 @@ func generateLayout(m *UI, w, h int) layout { appRect.Max.X -= 1 appRect.Max.Y -= 1 - if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) { + if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) { // extra padding on left and right for these states appRect.Min.X += 1 appRect.Max.X -= 1 @@ -597,6 +617,9 @@ func generateLayout(m *UI, w, h int) layout { // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + // Remove extra padding from editor (but keep it for header and main) + editorRect.Min.X -= 1 + editorRect.Max.X += 1 layout.header = headerRect layout.main = mainRect layout.editor = editorRect @@ -722,44 +745,6 @@ func (m *UI) randomizePlaceholders() { m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } -func (m *UI) initializeView() string { - cfg := m.com.Config() - s := m.com.Styles.Initialize - cwd := home.Short(cfg.WorkingDir()) - initFile := cfg.Options.InitializeAs - - header := s.Header.Render("Would you like to initialize this project?") - path := s.Accent.PaddingLeft(2).Render(cwd) - desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) - hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") - prompt := s.Content.Render("Would you like to initialize now?") - - buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ - {Text: "Yep!", Selected: m.yesInitializeSelected}, - {Text: "Nope", Selected: !m.yesInitializeSelected}, - }, " ") - - // max width 60 so the text is compact - width := min(m.layout.main.Dx(), 60) - - return lipgloss.NewStyle(). - Width(width). - Height(m.layout.main.Dy()). - PaddingBottom(1). - AlignVertical(lipgloss.Bottom). - Render(strings.Join( - []string{ - header, - path, - desc, - hint, - prompt, - buttons, - }, - "\n\n", - )) -} - func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently m.header = renderLogo(m.com.Styles, compact, width) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index da344b3a758b5b076ca5a19e89a702c3bcc4f383..0b005f0b83690b53ead7951c0a0979cf83c7a073 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -132,6 +132,14 @@ type Styles struct { Content lipgloss.Style Accent lipgloss.Style } + + // LSP + LSP struct { + ErrorDiagnostic lipgloss.Style + WarningDiagnostic lipgloss.Style + HintDiagnostic lipgloss.Style + InfoDiagnostic lipgloss.Style + } } func DefaultStyles() Styles { @@ -159,10 +167,8 @@ func DefaultStyles() Styles { borderFocus = charmtone.Charple // Status - // success = charmtone.Guac - // error = charmtone.Sriracha - // warning = charmtone.Zest - // info = charmtone.Malibu + warning = charmtone.Zest + info = charmtone.Malibu // Colors white = charmtone.Butter @@ -577,6 +583,18 @@ func DefaultStyles() Styles { s.Initialize.Header = s.Base s.Initialize.Content = s.Muted s.Initialize.Accent = s.Base.Foreground(greenDark) + + // LSP and MCP status. + s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●") + s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron) + s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral) + s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac) + + // LSP + s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark) + s.LSP.WarningDiagnostic = s.Base.Foreground(warning) + s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) + s.LSP.InfoDiagnostic = s.Base.Foreground(info) return s }