chore: initialize functionality & landing page

Kujtim Hoxha created

Change summary

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(-)

Detailed changes

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, " ")
+}

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
 }

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),
+		)
+}

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...)
+}

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...)
+}

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",
+		))
+}

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)

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
 }