Detailed changes
@@ -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, " ")
+}
@@ -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
}
@@ -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),
+ )
+}
@@ -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...)
+}
@@ -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...)
+}
@@ -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",
+ ))
+}
@@ -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)
@@ -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
}