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