@@ -1,7 +1,6 @@
1package model
1package model
6 "math/rand"
5 "math/rand"
@@ -13,8 +12,10 @@ import (
13 "charm.land/bubbles/v2/textarea"
12 "charm.land/bubbles/v2/textarea"
14 tea "charm.land/bubbletea/v2"
13 tea "charm.land/bubbletea/v2"
15 "charm.land/lipgloss/v2"
14 "charm.land/lipgloss/v2"
15+ "github.com/charmbracelet/crush/internal/agent/tools/mcp"
16+ "github.com/charmbracelet/crush/internal/app"
16 "github.com/charmbracelet/crush/internal/config"
17 "github.com/charmbracelet/crush/internal/config"
17− "github.com/charmbracelet/crush/internal/home"
18+ "github.com/charmbracelet/crush/internal/pubsub"
18 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/session"
19 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/common"
20 "github.com/charmbracelet/crush/internal/ui/dialog"
21 "github.com/charmbracelet/crush/internal/ui/dialog"
@@ -87,8 +88,16 @@ type UI struct {
87 readyPlaceholder string
88 readyPlaceholder string
88 workingPlaceholder string
89 workingPlaceholder string
90− // Initialize state
91+ // onboarding state
91− yesInitializeSelected bool
93+ yesInitializeSelected bool
97+ lspStates map[string]app.LSPClientInfo
100+ mcpStates map[string]mcp.ClientInfo
94// New creates a new instance of the [UI] model.
103// New creates a new instance of the [UI] model.
@@ -110,10 +119,11 @@ func New(com *common.Common) *UI {
110 focus: uiFocusNone,
119 focus: uiFocusNone,
111 state: uiConfigure,
120 state: uiConfigure,
112 textarea: ta,
121 textarea: ta,
115− yesInitializeSelected: true,
124+ // set onboarding state defaults
125+ ui.onboarding.yesInitializeSelected = true
117 // If no provider is configured show the user the provider list
127 // If no provider is configured show the user the provider list
118 if !com.Config().IsConfigured() {
128 if !com.Config().IsConfigured() {
119 ui.state = uiConfigure
129 ui.state = uiConfigure
@@ -129,6 +139,7 @@ func New(com *common.Common) *UI {
129 ui.setEditorPrompt()
139 ui.setEditorPrompt()
130 ui.randomizePlaceholders()
140 ui.randomizePlaceholders()
131 ui.textarea.Placeholder = ui.readyPlaceholder
141 ui.textarea.Placeholder = ui.readyPlaceholder
142+ ui.help.Styles = com.Styles.Help
133 return ui
144 return ui
@@ -144,13 +155,16 @@ func (m *UI) Init() tea.Cmd {
144// Update handles updates to the UI model.
155// Update handles updates to the UI model.
145func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
156func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
146 var cmds []tea.Cmd
157 var cmds []tea.Cmd
147− hasDialogs := m.dialog.HasDialogs()
148 switch msg := msg.(type) {
158 switch msg := msg.(type) {
149 case tea.EnvMsg:
159 case tea.EnvMsg:
150 // Is this Windows Terminal?
160 // Is this Windows Terminal?
151 if !m.sendProgressBar {
161 if !m.sendProgressBar {
152 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
162 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
164+ case pubsub.Event[app.LSPEvent]:
165+ m.lspStates = app.GetLSPStates()
166+ case pubsub.Event[mcp.Event]:
167+ m.mcpStates = mcp.GetStates()
154 case tea.TerminalVersionMsg:
168 case tea.TerminalVersionMsg:
155 termVersion := strings.ToLower(msg.Name)
169 termVersion := strings.ToLower(msg.Name)
156 // Only enable progress bar for the following terminals.
170 // Only enable progress bar for the following terminals.
@@ -168,60 +182,67 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
168 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
182 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
170 case tea.KeyPressMsg:
184 case tea.KeyPressMsg:
171− if hasDialogs {
185+ cmds = append(cmds, m.handleKeyPressMsg(msg)...)
172− m.updateDialogs(msg, &cmds)
176− if !hasDialogs {
188+ // This logic gets triggered on any message type, but should it?
177− // This branch only handles UI elements when there's no dialog shown.
178− switch msg := msg.(type) {
179− case tea.KeyPressMsg:
180− switch {
189+ switch m.focus {
181− case key.Matches(msg, m.keyMap.Tab):
182− if m.focus == uiFocusMain {
190+ case uiFocusMain:
183− m.focus = uiFocusEditor
191+ case uiFocusEditor:
184− cmds = append(cmds, m.textarea.Focus())
192+ // Textarea placeholder logic
185− } else {
193+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
186− m.focus = uiFocusMain
194+ m.textarea.Placeholder = m.workingPlaceholder
187− m.textarea.Blur()
195+ } else {
188− }
196+ m.textarea.Placeholder = m.readyPlaceholder
189− case key.Matches(msg, m.keyMap.Help):
197+ }
190− m.help.ShowAll = !m.help.ShowAll
198+ if m.com.App.Permissions.SkipRequests() {
191− m.updateLayoutAndSize()
199+ m.textarea.Placeholder = "Yolo mode!"
192− case key.Matches(msg, m.keyMap.Quit):
193− if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
194− m.dialog.AddDialog(dialog.NewQuit(m.com))
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
204− m.updateFocused(msg, &cmds)
208− // This logic gets triggered on any message type, but should it?
203+ return m, tea.Batch(cmds...)
206+func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
207+ if m.dialog.HasDialogs() {
208+ return m.updateDialogs(msg)
212+ case key.Matches(msg, m.keyMap.Tab):
209− switch m.focus {
213+ switch m.state {
210− case uiFocusMain:
215+ if m.focus == uiFocusMain {
211− case uiFocusEditor:
216+ m.focus = uiFocusEditor
212− // Textarea placeholder logic
217+ cmds = append(cmds, m.textarea.Focus())
213− if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
214− m.textarea.Placeholder = m.workingPlaceholder
216− m.textarea.Placeholder = m.readyPlaceholder
219+ m.focus = uiFocusMain
217− }
220+ m.textarea.Blur()
218− if m.com.App.Permissions.SkipRequests() {
219− m.textarea.Placeholder = "Yolo mode!"
223+ case key.Matches(msg, m.keyMap.Help):
224+ m.help.ShowAll = !m.help.ShowAll
225+ m.updateLayoutAndSize()
227+ case key.Matches(msg, m.keyMap.Quit):
228+ if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
229+ m.dialog.AddDialog(dialog.NewQuit(m.com))
233+ case key.Matches(msg, m.keyMap.Commands):
234+ // TODO: Implement me
236+ case key.Matches(msg, m.keyMap.Models):
237+ // TODO: Implement me
239+ case key.Matches(msg, m.keyMap.Sessions):
240+ // TODO: Implement me
224− return m, tea.Batch(cmds...)
244+ cmds = append(cmds, m.updateFocused(msg)...)
227// Draw implements [tea.Layer] and draws the UI model.
248// Draw implements [tea.Layer] and draws the UI model.
@@ -253,12 +274,7 @@ func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
253 case uiLanding:
274 case uiLanding:
254 header := uv.NewStyledString(m.header)
275 header := uv.NewStyledString(m.header)
255 header.Draw(scr, layout.header)
276 header.Draw(scr, layout.header)
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())
262 main.Draw(scr, layout.main)
278 main.Draw(scr, layout.main)
264 editor := uv.NewStyledString(m.textarea.View())
280 editor := uv.NewStyledString(m.textarea.View())
@@ -378,9 +394,10 @@ func (m *UI) ShortHelp() []key.Binding {
382− // we have a session
399+ // we have a session
385 // switch m.state {
402 // switch m.state {
386 // case uiChat:
403 // case uiChat:
@@ -400,7 +417,6 @@ func (m *UI) ShortHelp() []key.Binding {
406 return binds
422 return binds
@@ -438,9 +454,10 @@ func (m *UI) FullHelp() [][]key.Binding {
442− // we have a session
459+ // we have a session
446 // switch m.state {
463 // switch m.state {
@@ -452,44 +469,48 @@ func (m *UI) FullHelp() [][]key.Binding {
452 return binds
469 return binds
455−// updateDialogs updates the dialog overlay with the given message and appends
472+// updateDialogs updates the dialog overlay with the given message and returns cmds
456−// any resulting commands to the cmds slice.
457−func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
473+func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
458 updatedDialog, cmd := m.dialog.Update(msg)
474 updatedDialog, cmd := m.dialog.Update(msg)
459 m.dialog = updatedDialog
475 m.dialog = updatedDialog
461− *cmds = append(*cmds, cmd)
476+ cmds = append(cmds, cmd)
465// updateFocused updates the focused model (chat or editor) with the given message
480// updateFocused updates the focused model (chat or editor) with the given message
466// and appends any resulting commands to the cmds slice.
481// and appends any resulting commands to the cmds slice.
467−func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) {
482+func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
468− switch m.focus {
483+ switch m.state {
487+ return append(cmds, m.updateInitializeView(msg)...)
488+ case uiChat, uiLanding, uiChatCompact:
469− case uiFocusMain:
490+ case uiFocusMain:
470− m.updateChat(msg, cmds)
491+ cmds = append(cmds, m.updateChat(msg)...)
471− case uiFocusEditor:
492+ case uiFocusEditor:
472− switch {
493+ switch {
473− case key.Matches(msg, m.keyMap.Editor.Newline):
494+ case key.Matches(msg, m.keyMap.Editor.Newline):
474− m.textarea.InsertRune('\n')
495+ m.textarea.InsertRune('\n')
477− ta, cmd := m.textarea.Update(msg)
498+ ta, cmd := m.textarea.Update(msg)
478− m.textarea = ta
499+ m.textarea = ta
480− *cmds = append(*cmds, cmd)
500+ cmds = append(cmds, cmd)
485// updateChat updates the chat model with the given message and appends any
507// updateChat updates the chat model with the given message and appends any
486// resulting commands to the cmds slice.
508// 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) {
488 updatedChat, cmd := m.chat.Update(msg)
510 updatedChat, cmd := m.chat.Update(msg)
489 m.chat = updatedChat
511 m.chat = updatedChat
491− *cmds = append(*cmds, cmd)
512+ cmds = append(cmds, cmd)
495// updateLayoutAndSize updates the layout and sizes of UI components.
516// updateLayoutAndSize updates the layout and sizes of UI components.
@@ -509,7 +530,6 @@ func (m *UI) updateSize() {
509 m.renderHeader(false, m.layout.header.Dx())
530 m.renderHeader(false, m.layout.header.Dx())
511 case uiLanding:
532 case uiLanding:
512− // TODO: set the width and heigh of the chat component
513 m.renderHeader(false, m.layout.header.Dx())
533 m.renderHeader(false, m.layout.header.Dx())
514 m.textarea.SetWidth(m.layout.editor.Dx())
534 m.textarea.SetWidth(m.layout.editor.Dx())
515 m.textarea.SetHeight(m.layout.editor.Dy())
535 m.textarea.SetHeight(m.layout.editor.Dy())
@@ -557,7 +577,7 @@ func generateLayout(m *UI, w, h int) layout {
557 appRect.Max.X -= 1
577 appRect.Max.X -= 1
558 appRect.Max.Y -= 1
578 appRect.Max.Y -= 1
560− if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) {
580+ if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
561 // extra padding on left and right for these states
581 // extra padding on left and right for these states
562 appRect.Min.X += 1
582 appRect.Min.X += 1
563 appRect.Max.X -= 1
583 appRect.Max.X -= 1
@@ -597,6 +617,9 @@ func generateLayout(m *UI, w, h int) layout {
598 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
618 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
599 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
619 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
600 layout.header = headerRect
623 layout.header = headerRect
601 layout.main = mainRect
624 layout.main = mainRect
602 layout.editor = editorRect
625 layout.editor = editorRect
@@ -722,44 +745,6 @@ func (m *UI) randomizePlaceholders() {
722 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
745 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
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
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?")
737− buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{
738− {Text: "Yep!", Selected: m.yesInitializeSelected},
739− {Text: "Nope", Selected: !m.yesInitializeSelected},
742− // max width 60 so the text is compact
743− width := min(m.layout.main.Dx(), 60)
745− return lipgloss.NewStyle().
747− Height(m.layout.main.Dy()).
749− AlignVertical(lipgloss.Bottom).
750− Render(strings.Join(
763func (m *UI) renderHeader(compact bool, width int) {
748func (m *UI) renderHeader(compact bool, width int) {
764 // TODO: handle the compact case differently
749 // TODO: handle the compact case differently
765 m.header = renderLogo(m.com.Styles, compact, width)
750 m.header = renderLogo(m.com.Styles, compact, width)