on main help sign in
chore: initialize functionality & landing page
commitdb5c3eb3983e838eec277e959e42c6fa18f4272a authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 25 Nov 2025, 09:28 (1 year ago) parent4c223c3
8 files changed+628−131
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+112−127 internal/ui/styles/styles.go+22−4
internal/ui/common/elements.gonew file+121
@@ -0,0 +1,121 @@
1+package common
2+
3+import (
4+ "cmp"
5+ "fmt"
6+ "image/color"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "github.com/charmbracelet/crush/internal/home"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+func PrettyPath(t *styles.Styles, path string, width int) string {
16+ formatted := home.Short(path)
17+ return t.Muted.Width(width).Render(formatted)
18+}
19+
20+type ModelContextInfo struct {
21+ ContextUsed int64
22+ ModelContext int64
23+ Cost float64
24+}
25+
26+func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
27+ modelIcon := t.Subtle.Render(styles.ModelIcon)
28+ modelName = t.Base.Render(modelName)
29+ modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
30+
31+ parts := []string{
32+ modelInfo,
33+ }
34+
35+ if reasoningInfo != "" {
36+ parts = append(parts, t.Subtle.PaddingLeft(2).Render(reasoningInfo))
37+ }
38+
39+ if context != nil {
40+ parts = append(parts, formatTokensAndCost(t, context.ContextUsed, context.ModelContext, context.Cost))
41+ }
42+
43+ return lipgloss.NewStyle().Width(width).Render(
44+ lipgloss.JoinVertical(lipgloss.Left, parts...),
45+ )
46+}
47+
48+func formatTokensAndCost(t *styles.Styles, tokens, contextWindow int64, cost float64) string {
49+ var formattedTokens string
50+ switch {
51+ case tokens >= 1_000_000:
52+ formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
53+ case tokens >= 1_000:
54+ formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
55+ default:
56+ formattedTokens = fmt.Sprintf("%d", tokens)
57+ }
58+
59+ if strings.HasSuffix(formattedTokens, ".0K") {
60+ formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
61+ }
62+ if strings.HasSuffix(formattedTokens, ".0M") {
63+ formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
64+ }
65+
66+ percentage := (float64(tokens) / float64(contextWindow)) * 100
67+
68+ formattedCost := t.Muted.Render(fmt.Sprintf("$%.2f", cost))
69+
70+ formattedTokens = t.Subtle.Render(fmt.Sprintf("(%s)", formattedTokens))
71+ formattedPercentage := t.Muted.Render(fmt.Sprintf("%d%%", int(percentage)))
72+ formattedTokens = fmt.Sprintf("%s %s", formattedPercentage, formattedTokens)
73+ if percentage > 80 {
74+ formattedTokens = fmt.Sprintf("%s %s", styles.WarningIcon, formattedTokens)
75+ }
76+
77+ return fmt.Sprintf("%s %s", formattedTokens, formattedCost)
78+}
79+
80+type StatusOpts struct {
81+ Icon string // if empty no icon will be shown
82+ Title string
83+ TitleColor color.Color
84+ Description string
85+ DescriptionColor color.Color
86+ ExtraContent string // additional content to append after the description
87+}
88+
89+func Status(t *styles.Styles, opts StatusOpts, width int) string {
90+ icon := opts.Icon
91+ title := opts.Title
92+ description := opts.Description
93+
94+ titleColor := cmp.Or(opts.TitleColor, t.Muted.GetForeground())
95+ descriptionColor := cmp.Or(opts.DescriptionColor, t.Subtle.GetForeground())
96+
97+ title = t.Base.Foreground(titleColor).Render(title)
98+
99+ if description != "" {
100+ extraContentWidth := lipgloss.Width(opts.ExtraContent)
101+ if extraContentWidth > 0 {
102+ extraContentWidth += 1
103+ }
104+ description = ansi.Truncate(description, width-lipgloss.Width(icon)-lipgloss.Width(title)-2-extraContentWidth, "…")
105+ description = t.Base.Foreground(descriptionColor).Render(description)
106+ }
107+
108+ content := []string{}
109+ if icon != "" {
110+ content = append(content, icon)
111+ }
112+ content = append(content, title)
113+ if description != "" {
114+ content = append(content, description)
115+ }
116+ if opts.ExtraContent != "" {
117+ content = append(content, opts.ExtraContent)
118+ }
119+
120+ return strings.Join(content, " ")
121+}
internal/ui/model/keys.go+5
@@ -20,6 +20,7 @@ type KeyMap struct {
2020 Initialize struct {
2121 Yes,
2222 No,
23+ Enter,
2324 Switch key.Binding
2425 }
2526
@@ -117,6 +118,10 @@ func DefaultKeyMap() KeyMap {
117118 key.WithKeys("left", "right", "tab"),
118119 key.WithHelp("tab", "switch"),
119120 )
121+ km.Initialize.Enter = key.NewBinding(
122+ key.WithKeys("enter"),
123+ key.WithHelp("enter", "select"),
124+ )
120125
121126 return km
122127}
internal/ui/model/landing.gonew file+71
@@ -0,0 +1,71 @@
1+package model
2+
3+import (
4+ "cmp"
5+ "fmt"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/catwalk/pkg/catwalk"
9+ "github.com/charmbracelet/crush/internal/agent"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ uv "github.com/charmbracelet/ultraviolet"
12+ "golang.org/x/text/cases"
13+ "golang.org/x/text/language"
14+)
15+
16+func (m *UI) selectedLargeModel() *agent.Model {
17+ if m.com.App.AgentCoordinator != nil {
18+ model := m.com.App.AgentCoordinator.Model()
19+ return &model
20+ }
21+ return nil
22+}
23+
24+func (m *UI) landingView() string {
25+ t := m.com.Styles
26+ width := m.layout.main.Dx()
27+ cwd := common.PrettyPath(t, m.com.Config().WorkingDir(), width)
28+
29+ parts := []string{
30+ cwd,
31+ }
32+
33+ model := m.selectedLargeModel()
34+ if model != nil && model.CatwalkCfg.CanReason {
35+ reasoningInfo := ""
36+ providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
37+ if ok {
38+ switch providerConfig.Type {
39+ case catwalk.TypeAnthropic:
40+ if model.ModelCfg.Think {
41+ reasoningInfo = "Thinking On"
42+ } else {
43+ reasoningInfo = "Thinking Off"
44+ }
45+ default:
46+ formatter := cases.Title(language.English, cases.NoLower)
47+ reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
48+ reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
49+ }
50+ parts = append(parts, "", common.ModelInfo(t, model.CatwalkCfg.Name, reasoningInfo, nil, width))
51+ }
52+ }
53+ infoSection := lipgloss.JoinVertical(lipgloss.Left, parts...)
54+
55+ _, remainingHeightArea := uv.SplitVertical(m.layout.main, uv.Fixed(lipgloss.Height(infoSection)+1))
56+
57+ mcpLspSectionWidth := min(30, (width-1)/2)
58+
59+ lspSection := m.lspInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
60+ mcpSection := m.mcpInfo(t, mcpLspSectionWidth, remainingHeightArea.Dy())
61+
62+ content := lipgloss.JoinHorizontal(lipgloss.Left, lspSection, " ", mcpSection)
63+
64+ return lipgloss.NewStyle().
65+ Width(width).
66+ Height(m.layout.main.Dy() - 1).
67+ PaddingTop(1).
68+ Render(
69+ lipgloss.JoinVertical(lipgloss.Left, infoSection, "", content),
70+ )
71+}
internal/ui/model/lsp.gonew file+113
@@ -0,0 +1,113 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/app"
9+ "github.com/charmbracelet/crush/internal/lsp"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
13+)
14+
15+type LSPInfo struct {
16+ app.LSPClientInfo
17+ Diagnostics map[protocol.DiagnosticSeverity]int
18+}
19+
20+func (m *UI) lspInfo(t *styles.Styles, width, height int) string {
21+ var lsps []LSPInfo
22+
23+ for _, state := range m.lspStates {
24+ client, ok := m.com.App.LSPClients.Get(state.Name)
25+ if !ok {
26+ continue
27+ }
28+ lspErrs := map[protocol.DiagnosticSeverity]int{
29+ protocol.SeverityError: 0,
30+ protocol.SeverityWarning: 0,
31+ protocol.SeverityHint: 0,
32+ protocol.SeverityInformation: 0,
33+ }
34+
35+ for _, diagnostics := range client.GetDiagnostics() {
36+ for _, diagnostic := range diagnostics {
37+ if severity, ok := lspErrs[diagnostic.Severity]; ok {
38+ lspErrs[diagnostic.Severity] = severity + 1
39+ }
40+ }
41+ }
42+
43+ lsps = append(lsps, LSPInfo{LSPClientInfo: state, Diagnostics: lspErrs})
44+ }
45+ title := t.Subtle.Render("LSPs")
46+ list := t.Subtle.Render("None")
47+ if len(lsps) > 0 {
48+ height = max(0, height-2) // remove title and space
49+ list = lspList(t, lsps, width, height)
50+ }
51+
52+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
53+}
54+
55+func lspDiagnostics(t *styles.Styles, diagnostics map[protocol.DiagnosticSeverity]int) string {
56+ errs := []string{}
57+ if diagnostics[protocol.SeverityError] > 0 {
58+ errs = append(errs, t.LSP.ErrorDiagnostic.Render(fmt.Sprintf("%s %d", styles.ErrorIcon, diagnostics[protocol.SeverityError])))
59+ }
60+ if diagnostics[protocol.SeverityWarning] > 0 {
61+ errs = append(errs, t.LSP.WarningDiagnostic.Render(fmt.Sprintf("%s %d", styles.WarningIcon, diagnostics[protocol.SeverityWarning])))
62+ }
63+ if diagnostics[protocol.SeverityHint] > 0 {
64+ errs = append(errs, t.LSP.HintDiagnostic.Render(fmt.Sprintf("%s %d", styles.HintIcon, diagnostics[protocol.SeverityHint])))
65+ }
66+ if diagnostics[protocol.SeverityInformation] > 0 {
67+ errs = append(errs, t.LSP.InfoDiagnostic.Render(fmt.Sprintf("%s %d", styles.InfoIcon, diagnostics[protocol.SeverityInformation])))
68+ }
69+ return strings.Join(errs, " ")
70+}
71+
72+func lspList(t *styles.Styles, lsps []LSPInfo, width, height int) string {
73+ var renderedLsps []string
74+ for _, l := range lsps {
75+ var icon string
76+ title := l.Name
77+ var description string
78+ var diagnostics string
79+ switch l.State {
80+ case lsp.StateStarting:
81+ icon = t.ItemBusyIcon.String()
82+ description = t.Subtle.Render("starting...")
83+ case lsp.StateReady:
84+ icon = t.ItemOnlineIcon.String()
85+ diagnostics = lspDiagnostics(t, l.Diagnostics)
86+ case lsp.StateError:
87+ icon = t.ItemErrorIcon.String()
88+ description = t.Subtle.Render("error")
89+ if l.Error != nil {
90+ description = t.Subtle.Render(fmt.Sprintf("error: %s", l.Error.Error()))
91+ }
92+ case lsp.StateDisabled:
93+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
94+ description = t.Subtle.Render("inactive")
95+ default:
96+ icon = t.ItemOfflineIcon.String()
97+ }
98+ renderedLsps = append(renderedLsps, common.Status(t, common.StatusOpts{
99+ Icon: icon,
100+ Title: title,
101+ Description: description,
102+ ExtraContent: diagnostics,
103+ }, width))
104+ }
105+
106+ if len(renderedLsps) > height {
107+ visibleItems := renderedLsps[:height-1]
108+ remaining := len(renderedLsps) - (height - 1)
109+ visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
110+ return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
111+ }
112+ return lipgloss.JoinVertical(lipgloss.Left, renderedLsps...)
113+}
internal/ui/model/mcp.gonew file+88
@@ -0,0 +1,88 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "charm.land/lipgloss/v2"
8+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
9+ "github.com/charmbracelet/crush/internal/ui/common"
10+ "github.com/charmbracelet/crush/internal/ui/styles"
11+)
12+
13+type MCPInfo struct {
14+ mcp.ClientInfo
15+}
16+
17+func (m *UI) mcpInfo(t *styles.Styles, width, height int) string {
18+ var mcps []MCPInfo
19+
20+ for _, state := range m.mcpStates {
21+ mcps = append(mcps, MCPInfo{ClientInfo: state})
22+ }
23+
24+ title := t.Subtle.Render("MCPs")
25+ list := t.Subtle.Render("None")
26+ if len(mcps) > 0 {
27+ height = max(0, height-2) // remove title and space
28+ list = mcpList(t, mcps, width, height)
29+ }
30+
31+ return lipgloss.NewStyle().Width(width).Render(fmt.Sprintf("%s\n\n%s", title, list))
32+}
33+
34+func mcpCounts(t *styles.Styles, counts mcp.Counts) string {
35+ parts := []string{}
36+ if counts.Tools > 0 {
37+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d tools", counts.Tools)))
38+ }
39+ if counts.Prompts > 0 {
40+ parts = append(parts, t.Subtle.Render(fmt.Sprintf("%d prompts", counts.Prompts)))
41+ }
42+ return strings.Join(parts, " ")
43+}
44+
45+func mcpList(t *styles.Styles, mcps []MCPInfo, width, height int) string {
46+ var renderedMcps []string
47+ for _, m := range mcps {
48+ var icon string
49+ title := m.Name
50+ var description string
51+ var extraContent string
52+
53+ switch m.State {
54+ case mcp.StateStarting:
55+ icon = t.ItemBusyIcon.String()
56+ description = t.Subtle.Render("starting...")
57+ case mcp.StateConnected:
58+ icon = t.ItemOnlineIcon.String()
59+ extraContent = mcpCounts(t, m.Counts)
60+ case mcp.StateError:
61+ icon = t.ItemErrorIcon.String()
62+ description = t.Subtle.Render("error")
63+ if m.Error != nil {
64+ description = t.Subtle.Render(fmt.Sprintf("error: %s", m.Error.Error()))
65+ }
66+ case mcp.StateDisabled:
67+ icon = t.ItemOfflineIcon.Foreground(t.Muted.GetBackground()).String()
68+ description = t.Subtle.Render("disabled")
69+ default:
70+ icon = t.ItemOfflineIcon.String()
71+ }
72+
73+ renderedMcps = append(renderedMcps, common.Status(t, common.StatusOpts{
74+ Icon: icon,
75+ Title: title,
76+ Description: description,
77+ ExtraContent: extraContent,
78+ }, width))
79+ }
80+
81+ if len(renderedMcps) > height {
82+ visibleItems := renderedMcps[:height-1]
83+ remaining := len(renderedMcps) - (height - 1)
84+ visibleItems = append(visibleItems, t.Subtle.Render(fmt.Sprintf("…and %d more", remaining)))
85+ return lipgloss.JoinVertical(lipgloss.Left, visibleItems...)
86+ }
87+ return lipgloss.JoinVertical(lipgloss.Left, renderedMcps...)
88+}
internal/ui/model/onboarding.gonew file+96
@@ -0,0 +1,96 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "log/slog"
6+ "strings"
7+
8+ "charm.land/bubbles/v2/key"
9+ tea "charm.land/bubbletea/v2"
10+ "charm.land/lipgloss/v2"
11+ "github.com/charmbracelet/crush/internal/config"
12+ "github.com/charmbracelet/crush/internal/home"
13+ "github.com/charmbracelet/crush/internal/ui/common"
14+)
15+
16+func (m *UI) markProjectInitialized() tea.Msg {
17+ // TODO: handle error so we show it in the tui footer
18+ err := config.MarkProjectInitialized()
19+ if err != nil {
20+ slog.Error(err.Error())
21+ }
22+ return nil
23+}
24+
25+func (m *UI) updateInitializeView(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
26+ switch {
27+ case key.Matches(msg, m.keyMap.Initialize.Enter):
28+ if m.onboarding.yesInitializeSelected {
29+ cmds = append(cmds, m.initializeProject())
30+ } else {
31+ cmds = append(cmds, m.skipInitializeProject())
32+ }
33+ case key.Matches(msg, m.keyMap.Initialize.Switch):
34+ m.onboarding.yesInitializeSelected = !m.onboarding.yesInitializeSelected
35+ case key.Matches(msg, m.keyMap.Initialize.Yes):
36+ cmds = append(cmds, m.initializeProject())
37+ case key.Matches(msg, m.keyMap.Initialize.No):
38+ cmds = append(cmds, m.skipInitializeProject())
39+ }
40+ return cmds
41+}
42+
43+func (m *UI) initializeProject() tea.Cmd {
44+ // TODO: initialize the project
45+ // for now we just go to the landing page
46+ m.state = uiLanding
47+ m.focus = uiFocusEditor
48+ // TODO: actually send a message to the agent
49+ return m.markProjectInitialized
50+}
51+
52+func (m *UI) skipInitializeProject() tea.Cmd {
53+ // TODO: initialize the project
54+ m.state = uiLanding
55+ m.focus = uiFocusEditor
56+ // mark the project as initialized
57+ return m.markProjectInitialized
58+}
59+
60+func (m *UI) initializeView() string {
61+ cfg := m.com.Config()
62+ s := m.com.Styles.Initialize
63+ cwd := home.Short(cfg.WorkingDir())
64+ initFile := cfg.Options.InitializeAs
65+
66+ header := s.Header.Render("Would you like to initialize this project?")
67+ path := s.Accent.PaddingLeft(2).Render(cwd)
68+ 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))
69+ hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
70+ prompt := s.Content.Render("Would you like to initialize now?")
71+
72+ buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
73+ {Text: "Yep!", Selected: m.onboarding.yesInitializeSelected},
74+ {Text: "Nope", Selected: !m.onboarding.yesInitializeSelected},
75+ }, " ")
76+
77+ // max width 60 so the text is compact
78+ width := min(m.layout.main.Dx(), 60)
79+
80+ return lipgloss.NewStyle().
81+ Width(width).
82+ Height(m.layout.main.Dy()).
83+ PaddingBottom(1).
84+ AlignVertical(lipgloss.Bottom).
85+ Render(strings.Join(
86+ []string{
87+ header,
88+ path,
89+ desc,
90+ hint,
91+ prompt,
92+ buttons,
93+ },
94+ "\n\n",
95+ ))
96+}
internal/ui/model/ui.go+112−127
@@ -1,7 +1,6 @@
11package model
22
33import (
4− "fmt"
54 "image"
65 "math/rand"
76 "os"
@@ -13,8 +12,10 @@ import (
1312 "charm.land/bubbles/v2/textarea"
1413 tea "charm.land/bubbletea/v2"
1514 "charm.land/lipgloss/v2"
15+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16+ "github.com/charmbracelet/crush/internal/app"
1617 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/home"
18+ "github.com/charmbracelet/crush/internal/pubsub"
1819 "github.com/charmbracelet/crush/internal/session"
1920 "github.com/charmbracelet/crush/internal/ui/common"
2021 "github.com/charmbracelet/crush/internal/ui/dialog"
@@ -87,8 +88,16 @@ type UI struct {
8788 readyPlaceholder string
8889 workingPlaceholder string
8990
90− // Initialize state
91− yesInitializeSelected bool
91+ // onboarding state
92+ onboarding struct {
93+ yesInitializeSelected bool
94+ }
95+
96+ // lsp
97+ lspStates map[string]app.LSPClientInfo
98+
99+ // mcp
100+ mcpStates map[string]mcp.ClientInfo
92101}
93102
94103// New creates a new instance of the [UI] model.
@@ -110,10 +119,11 @@ func New(com *common.Common) *UI {
110119 focus: uiFocusNone,
111120 state: uiConfigure,
112121 textarea: ta,
113−
114− // initialize
115− yesInitializeSelected: true,
116122 }
123+
124+ // set onboarding state defaults
125+ ui.onboarding.yesInitializeSelected = true
126+
117127 // If no provider is configured show the user the provider list
118128 if !com.Config().IsConfigured() {
119129 ui.state = uiConfigure
@@ -129,6 +139,7 @@ func New(com *common.Common) *UI {
129139 ui.setEditorPrompt()
130140 ui.randomizePlaceholders()
131141 ui.textarea.Placeholder = ui.readyPlaceholder
142+ ui.help.Styles = com.Styles.Help
132143
133144 return ui
134145}
@@ -144,13 +155,16 @@ func (m *UI) Init() tea.Cmd {
144155// Update handles updates to the UI model.
145156func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146157 var cmds []tea.Cmd
147− hasDialogs := m.dialog.HasDialogs()
148158 switch msg := msg.(type) {
149159 case tea.EnvMsg:
150160 // Is this Windows Terminal?
151161 if !m.sendProgressBar {
152162 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
153163 }
164+ case pubsub.Event[app.LSPEvent]:
165+ m.lspStates = app.GetLSPStates()
166+ case pubsub.Event[mcp.Event]:
167+ m.mcpStates = mcp.GetStates()
154168 case tea.TerminalVersionMsg:
155169 termVersion := strings.ToLower(msg.Name)
156170 // Only enable progress bar for the following terminals.
@@ -168,60 +182,67 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168182 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
169183 }
170184 case tea.KeyPressMsg:
171− if hasDialogs {
172− m.updateDialogs(msg, &cmds)
173− }
185+ cmds = append(cmds, m.handleKeyPressMsg(msg)...)
174186 }
175187
176− if !hasDialogs {
177− // This branch only handles UI elements when there's no dialog shown.
178− switch msg := msg.(type) {
179− case tea.KeyPressMsg:
180− switch {
181− case key.Matches(msg, m.keyMap.Tab):
182− if m.focus == uiFocusMain {
183− m.focus = uiFocusEditor
184− cmds = append(cmds, m.textarea.Focus())
185− } else {
186− m.focus = uiFocusMain
187− m.textarea.Blur()
188− }
189− case key.Matches(msg, m.keyMap.Help):
190− m.help.ShowAll = !m.help.ShowAll
191− m.updateLayoutAndSize()
192− case key.Matches(msg, m.keyMap.Quit):
193− if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
194− m.dialog.AddDialog(dialog.NewQuit(m.com))
195− return m, nil
196− }
197− case key.Matches(msg, m.keyMap.Commands):
198− // TODO: Implement me
199− case key.Matches(msg, m.keyMap.Models):
200− // TODO: Implement me
201− case key.Matches(msg, m.keyMap.Sessions):
202− // TODO: Implement me
203− default:
204− m.updateFocused(msg, &cmds)
205− }
188+ // This logic gets triggered on any message type, but should it?
189+ switch m.focus {
190+ case uiFocusMain:
191+ case uiFocusEditor:
192+ // Textarea placeholder logic
193+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
194+ m.textarea.Placeholder = m.workingPlaceholder
195+ } else {
196+ m.textarea.Placeholder = m.readyPlaceholder
197+ }
198+ if m.com.App.Permissions.SkipRequests() {
199+ m.textarea.Placeholder = "Yolo mode!"
206200 }
201+ }
207202
208− // This logic gets triggered on any message type, but should it?
209− switch m.focus {
210− case uiFocusMain:
211− case uiFocusEditor:
212− // Textarea placeholder logic
213− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
214− m.textarea.Placeholder = m.workingPlaceholder
203+ return m, tea.Batch(cmds...)
204+}
205+
206+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207+ if m.dialog.HasDialogs() {
208+ return m.updateDialogs(msg)
209+ }
210+
211+ switch {
212+ case key.Matches(msg, m.keyMap.Tab):
213+ switch m.state {
214+ case uiChat:
215+ if m.focus == uiFocusMain {
216+ m.focus = uiFocusEditor
217+ cmds = append(cmds, m.textarea.Focus())
215218 } else {
216− m.textarea.Placeholder = m.readyPlaceholder
217− }
218− if m.com.App.Permissions.SkipRequests() {
219− m.textarea.Placeholder = "Yolo mode!"
219+ m.focus = uiFocusMain
220+ m.textarea.Blur()
220221 }
221222 }
223+ case key.Matches(msg, m.keyMap.Help):
224+ m.help.ShowAll = !m.help.ShowAll
225+ m.updateLayoutAndSize()
226+ return cmds
227+ case key.Matches(msg, m.keyMap.Quit):
228+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
229+ m.dialog.AddDialog(dialog.NewQuit(m.com))
230+ return
231+ }
232+ return cmds
233+ case key.Matches(msg, m.keyMap.Commands):
234+ // TODO: Implement me
235+ return cmds
236+ case key.Matches(msg, m.keyMap.Models):
237+ // TODO: Implement me
238+ return cmds
239+ case key.Matches(msg, m.keyMap.Sessions):
240+ // TODO: Implement me
241+ return cmds
222242 }
223243
224− return m, tea.Batch(cmds...)
244+ cmds = append(cmds, m.updateFocused(msg)...)
245+ return cmds
225246}
226247
227248// Draw implements [tea.Layer] and draws the UI model.
@@ -253,12 +274,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
253274 case uiLanding:
254275 header := uv.NewStyledString(m.header)
255276 header.Draw(scr, layout.header)
256−
257− mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
258− Height(layout.main.Dy()).
259− Background(lipgloss.ANSIColor(rand.Intn(256))).
260− Render(" Landing Page ")
261− main := uv.NewStyledString(mainView)
277+ main := uv.NewStyledString(m.landingView())
262278 main.Draw(scr, layout.main)
263279
264280 editor := uv.NewStyledString(m.textarea.View())
@@ -378,9 +394,10 @@ func (m *UI) ShortHelp() []key.Binding {
378394 k.Quit,
379395 k.Help,
380396 )
381− } else {
382− // we have a session
383397 }
398+ // else {
399+ // we have a session
400+ // }
384401
385402 // switch m.state {
386403 // case uiChat:
@@ -400,7 +417,6 @@ func (m *UI) ShortHelp() []key.Binding {
400417 // )
401418 // }
402419 // }
403−
404420 }
405421
406422 return binds
@@ -438,9 +454,10 @@ func (m *UI) FullHelp() [][]key.Binding {
438454 help,
439455 },
440456 )
441− } else {
442− // we have a session
443457 }
458+ // else {
459+ // we have a session
460+ // }
444461 }
445462
446463 // switch m.state {
@@ -452,44 +469,48 @@ func (m *UI) FullHelp() [][]key.Binding {
452469 return binds
453470}
454471
455−// updateDialogs updates the dialog overlay with the given message and appends
456−// any resulting commands to the cmds slice.
457−func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
472+// updateDialogs updates the dialog overlay with the given message and returns cmds
473+func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
458474 updatedDialog, cmd := m.dialog.Update(msg)
459475 m.dialog = updatedDialog
460− if cmd != nil {
461− *cmds = append(*cmds, cmd)
462− }
476+ cmds = append(cmds, cmd)
477+ return cmds
463478}
464479
465480// updateFocused updates the focused model (chat or editor) with the given message
466481// and appends any resulting commands to the cmds slice.
467−func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
468− switch m.focus {
469− case uiFocusMain:
470− m.updateChat(msg, cmds)
471− case uiFocusEditor:
472− switch {
473− case key.Matches(msg, m.keyMap.Editor.Newline):
474− m.textarea.InsertRune('\n')
475− }
482+func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
483+ switch m.state {
484+ case uiConfigure:
485+ return cmds
486+ case uiInitialize:
487+ return append(cmds, m.updateInitializeView(msg)...)
488+ case uiChat, uiLanding, uiChatCompact:
489+ switch m.focus {
490+ case uiFocusMain:
491+ cmds = append(cmds, m.updateChat(msg)...)
492+ case uiFocusEditor:
493+ switch {
494+ case key.Matches(msg, m.keyMap.Editor.Newline):
495+ m.textarea.InsertRune('\n')
496+ }
476497
477− ta, cmd := m.textarea.Update(msg)
478− m.textarea = ta
479− if cmd != nil {
480− *cmds = append(*cmds, cmd)
498+ ta, cmd := m.textarea.Update(msg)
499+ m.textarea = ta
500+ cmds = append(cmds, cmd)
501+ return cmds
481502 }
482503 }
504+ return cmds
483505}
484506
485507// updateChat updates the chat model with the given message and appends any
486508// resulting commands to the cmds slice.
487−func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
509+func (m *UI) updateChat(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
488510 updatedChat, cmd := m.chat.Update(msg)
489511 m.chat = updatedChat
490− if cmd != nil {
491− *cmds = append(*cmds, cmd)
492− }
512+ cmds = append(cmds, cmd)
513+ return cmds
493514}
494515
495516// updateLayoutAndSize updates the layout and sizes of UI components.
@@ -509,7 +530,6 @@ func (m *UI) updateSize() {
509530 m.renderHeader(false, m.layout.header.Dx())
510531
511532 case uiLanding:
512− // TODO: set the width and heigh of the chat component
513533 m.renderHeader(false, m.layout.header.Dx())
514534 m.textarea.SetWidth(m.layout.editor.Dx())
515535 m.textarea.SetHeight(m.layout.editor.Dy())
@@ -557,7 +577,7 @@ func generateLayout(m *UI, w, h int) layout {
557577 appRect.Max.X -= 1
558578 appRect.Max.Y -= 1
559579
560− if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
580+ if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
561581 // extra padding on left and right for these states
562582 appRect.Min.X += 1
563583 appRect.Max.X -= 1
@@ -597,6 +617,9 @@ func generateLayout(m *UI, w, h int) layout {
597617 // help
598618 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
599619 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
620+ // Remove extra padding from editor (but keep it for header and main)
621+ editorRect.Min.X -= 1
622+ editorRect.Max.X += 1
600623 layout.header = headerRect
601624 layout.main = mainRect
602625 layout.editor = editorRect
@@ -722,44 +745,6 @@ func (m *UI) randomizePlaceholders() {
722745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
723746}
724747
725−func (m *UI) initializeView() string {
726− cfg := m.com.Config()
727− s := m.com.Styles.Initialize
728− cwd := home.Short(cfg.WorkingDir())
729− initFile := cfg.Options.InitializeAs
730−
731− header := s.Header.Render("Would you like to initialize this project?")
732− path := s.Accent.PaddingLeft(2).Render(cwd)
733− 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))
734− hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".")
735− prompt := s.Content.Render("Would you like to initialize now?")
736−
737− buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
738− {Text: "Yep!", Selected: m.yesInitializeSelected},
739− {Text: "Nope", Selected: !m.yesInitializeSelected},
740− }, " ")
741−
742− // max width 60 so the text is compact
743− width := min(m.layout.main.Dx(), 60)
744−
745− return lipgloss.NewStyle().
746− Width(width).
747− Height(m.layout.main.Dy()).
748− PaddingBottom(1).
749− AlignVertical(lipgloss.Bottom).
750− Render(strings.Join(
751− []string{
752− header,
753− path,
754− desc,
755− hint,
756− prompt,
757− buttons,
758− },
759− "\n\n",
760− ))
761−}
762−
763748func (m *UI) renderHeader(compact bool, width int) {
764749 // TODO: handle the compact case differently
765750 m.header = renderLogo(m.com.Styles, compact, width)
internal/ui/styles/styles.go+22−4
@@ -132,6 +132,14 @@ type Styles struct {
132132 Content lipgloss.Style
133133 Accent lipgloss.Style
134134 }
135+
136+ // LSP
137+ LSP struct {
138+ ErrorDiagnostic lipgloss.Style
139+ WarningDiagnostic lipgloss.Style
140+ HintDiagnostic lipgloss.Style
141+ InfoDiagnostic lipgloss.Style
142+ }
135143}
136144
137145func DefaultStyles() Styles {
@@ -159,10 +167,8 @@ func DefaultStyles() Styles {
159167 borderFocus = charmtone.Charple
160168
161169 // Status
162− // success = charmtone.Guac
163− // error = charmtone.Sriracha
164− // warning = charmtone.Zest
165− // info = charmtone.Malibu
170+ warning = charmtone.Zest
171+ info = charmtone.Malibu
166172
167173 // Colors
168174 white = charmtone.Butter
@@ -577,6 +583,18 @@ func DefaultStyles() Styles {
577583 s.Initialize.Header = s.Base
578584 s.Initialize.Content = s.Muted
579585 s.Initialize.Accent = s.Base.Foreground(greenDark)
586+
587+ // LSP and MCP status.
588+ s.ItemOfflineIcon = lipgloss.NewStyle().Foreground(charmtone.Squid).SetString("●")
589+ s.ItemBusyIcon = s.ItemOfflineIcon.Foreground(charmtone.Citron)
590+ s.ItemErrorIcon = s.ItemOfflineIcon.Foreground(charmtone.Coral)
591+ s.ItemOnlineIcon = s.ItemOfflineIcon.Foreground(charmtone.Guac)
592+
593+ // LSP
594+ s.LSP.ErrorDiagnostic = s.Base.Foreground(redDark)
595+ s.LSP.WarningDiagnostic = s.Base.Foreground(warning)
596+ s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
597+ s.LSP.InfoDiagnostic = s.Base.Foreground(info)
580598 return s
581599}
582600