12 "charm.land/bubbles/v2/help"
13 "charm.land/bubbles/v2/key"
14 "charm.land/bubbles/v2/textarea"
15 tea "charm.land/bubbletea/v2"
16 "charm.land/lipgloss/v2"
17 "github.com/charmbracelet/crush/internal/agent/tools/mcp"
18 "github.com/charmbracelet/crush/internal/app"
19 "github.com/charmbracelet/crush/internal/config"
20 "github.com/charmbracelet/crush/internal/history"
21 "github.com/charmbracelet/crush/internal/message"
22 "github.com/charmbracelet/crush/internal/pubsub"
23 "github.com/charmbracelet/crush/internal/session"
24 "github.com/charmbracelet/crush/internal/ui/common"
25 "github.com/charmbracelet/crush/internal/ui/dialog"
26 "github.com/charmbracelet/crush/internal/ui/logo"
27 "github.com/charmbracelet/crush/internal/ui/styles"
28 "github.com/charmbracelet/crush/internal/version"
29 uv "github.com/charmbracelet/ultraviolet"
30 "github.com/charmbracelet/ultraviolet/screen"
33// uiFocusState represents the current focus state of the UI.
34type uiFocusState uint8
36// Possible uiFocusState values.
38 uiFocusNone uiFocusState = iota
45// Possible uiState values.
47 uiConfigure uiState = iota
54type sessionLoadedMsg struct {
58type sessionFilesLoadedMsg struct {
62// UI represents the main user interface model.
65 session *session.Session
66 sessionFiles []SessionFile
68 // The width and height of the terminal in cells.
77 keyenh tea.KeyboardEnhancementsMsg
79 dialog *dialog.Overlay
82 // header is the last cached header logo
85 // sendProgressBar instructs the TUI to send progress bar updates to the
89 // QueryVersion instructs the TUI to query for the terminal version when it
94 textarea textarea.Model
96 attachments []any // TODO: Implement attachments
98 readyPlaceholder string
99 workingPlaceholder string
106 yesInitializeSelected bool
110 lspStates map[string]app.LSPClientInfo
113 mcpStates map[string]mcp.ClientInfo
115 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
119// New creates a new instance of the [UI] model.
120func New(com *common.Common) *UI {
123 ta.SetStyles(com.Styles.TextArea)
124 ta.ShowLineNumbers = false
126 ta.SetVirtualCursor(false)
133 dialog: dialog.NewOverlay(),
134 keyMap: DefaultKeyMap(),
142 // set onboarding state defaults
143 ui.onboarding.yesInitializeSelected = true
145 // If no provider is configured show the user the provider list
146 if !com.Config().IsConfigured() {
147 ui.state = uiConfigure
148 // if the project needs initialization show the user the question
149 } else if n, _ := config.ProjectNeedsInitialization(); n {
150 ui.state = uiInitialize
151 // otherwise go to the landing UI
154 ui.focus = uiFocusEditor
158 ui.randomizePlaceholders()
159 ui.textarea.Placeholder = ui.readyPlaceholder
160 ui.help.Styles = com.Styles.Help
165// Init initializes the UI model.
166func (m *UI) Init() tea.Cmd {
169 cmds = append(cmds, tea.RequestTerminalVersion)
171 allSessions, _ := m.com.App.Sessions.List(context.Background())
172 if len(allSessions) > 0 {
173 cmds = append(cmds, func() tea.Msg {
174 time.Sleep(2 * time.Second)
175 return m.loadSession(allSessions[1].ID)()
178 return tea.Batch(cmds...)
181// sessionLoadedDoneMsg indicates that session loading and message appending is
183type sessionLoadedDoneMsg struct{}
185// Update handles updates to the UI model.
186func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
188 switch msg := msg.(type) {
190 // Is this Windows Terminal?
191 if !m.sendProgressBar {
192 m.sendProgressBar = slices.Contains(msg, "WT_SESSION")
194 case sessionLoadedMsg:
196 m.session = &msg.sess
197 // Load the last 20 messages from this session.
198 msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
200 // Build tool result map to link tool calls with their results
201 msgPtrs := make([]*message.Message, len(msgs))
202 for i := range msgs {
203 msgPtrs[i] = &msgs[i]
205 toolResultMap := BuildToolResultMap(msgPtrs)
207 // Add messages to chat with linked tool results
208 items := make([]MessageItem, 0, len(msgs)*2)
209 for _, msg := range msgPtrs {
210 items = append(items, GetMessageItems(msg, toolResultMap)...)
212 m.chat.AppendMessages(items...)
214 // Notify that session loading is done to scroll to bottom. This is
215 // needed because we need to draw the chat list first before we can
217 cmds = append(cmds, func() tea.Msg {
218 return sessionLoadedDoneMsg{}
220 case sessionLoadedDoneMsg:
221 m.chat.ScrollToBottom()
223 case sessionFilesLoadedMsg:
224 m.sessionFiles = msg.files
225 case pubsub.Event[history.File]:
226 cmds = append(cmds, m.handleFileEvent(msg.Payload))
227 case pubsub.Event[app.LSPEvent]:
228 m.lspStates = app.GetLSPStates()
229 case pubsub.Event[mcp.Event]:
230 m.mcpStates = mcp.GetStates()
231 case tea.TerminalVersionMsg:
232 termVersion := strings.ToLower(msg.Name)
233 // Only enable progress bar for the following terminals.
234 if !m.sendProgressBar {
235 m.sendProgressBar = strings.Contains(termVersion, "ghostty")
238 case tea.WindowSizeMsg:
239 m.width, m.height = msg.Width, msg.Height
240 m.updateLayoutAndSize()
241 case tea.KeyboardEnhancementsMsg:
243 if msg.SupportsKeyDisambiguation() {
244 m.keyMap.Models.SetHelp("ctrl+m", "models")
245 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
247 case tea.MouseClickMsg:
250 m.chat.HandleMouseDown(msg.X, msg.Y)
253 case tea.MouseMotionMsg:
258 } else if msg.Y >= m.chat.Height()-1 {
261 m.chat.HandleMouseDrag(msg.X, msg.Y)
264 case tea.MouseReleaseMsg:
267 m.chat.HandleMouseUp(msg.X, msg.Y)
269 case tea.MouseWheelMsg:
273 case tea.MouseWheelUp:
275 if !m.chat.SelectedItemInView() {
277 m.chat.ScrollToSelected()
279 case tea.MouseWheelDown:
281 if !m.chat.SelectedItemInView() {
283 m.chat.ScrollToSelected()
287 case tea.KeyPressMsg:
288 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
291 // This logic gets triggered on any message type, but should it?
295 // Textarea placeholder logic
296 if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
297 m.textarea.Placeholder = m.workingPlaceholder
299 m.textarea.Placeholder = m.readyPlaceholder
301 if m.com.App.Permissions.SkipRequests() {
302 m.textarea.Placeholder = "Yolo mode!"
306 return m, tea.Batch(cmds...)
309func (m *UI) loadSession(sessionID string) tea.Cmd {
310 return func() tea.Msg {
311 // TODO: handle error
312 session, _ := m.com.App.Sessions.Get(context.Background(), sessionID)
313 return sessionLoadedMsg{session}
317func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
318 if m.dialog.HasDialogs() {
319 return m.updateDialogs(msg)
322 handleGlobalKeys := func(msg tea.KeyPressMsg) {
324 case key.Matches(msg, m.keyMap.Tab):
325 case key.Matches(msg, m.keyMap.Help):
326 m.help.ShowAll = !m.help.ShowAll
327 m.updateLayoutAndSize()
328 case key.Matches(msg, m.keyMap.Quit):
329 if !m.dialog.ContainsDialog(dialog.QuitDialogID) {
330 m.dialog.AddDialog(dialog.NewQuit(m.com))
333 case key.Matches(msg, m.keyMap.Commands):
334 // TODO: Implement me
335 case key.Matches(msg, m.keyMap.Models):
336 // TODO: Implement me
337 case key.Matches(msg, m.keyMap.Sessions):
338 // TODO: Implement me
345 case key.Matches(msg, m.keyMap.Tab):
346 if m.focus == uiFocusMain {
347 m.focus = uiFocusEditor
348 cmds = append(cmds, m.textarea.Focus())
351 m.focus = uiFocusMain
354 m.chat.SetSelected(m.chat.Len() - 1)
356 case key.Matches(msg, m.keyMap.Chat.Up):
358 if !m.chat.SelectedItemInView() {
360 m.chat.ScrollToSelected()
362 case key.Matches(msg, m.keyMap.Chat.Down):
364 if !m.chat.SelectedItemInView() {
366 m.chat.ScrollToSelected()
368 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
370 m.chat.ScrollToSelected()
371 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
373 m.chat.ScrollToSelected()
374 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
375 m.chat.ScrollBy(-m.chat.Height() / 2)
376 m.chat.SelectFirstInView()
377 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
378 m.chat.ScrollBy(m.chat.Height() / 2)
379 m.chat.SelectLastInView()
380 case key.Matches(msg, m.keyMap.Chat.PageUp):
381 m.chat.ScrollBy(-m.chat.Height())
382 m.chat.SelectFirstInView()
383 case key.Matches(msg, m.keyMap.Chat.PageDown):
384 m.chat.ScrollBy(m.chat.Height())
385 m.chat.SelectLastInView()
386 case key.Matches(msg, m.keyMap.Chat.Home):
389 case key.Matches(msg, m.keyMap.Chat.End):
390 m.chat.ScrollToBottom()
393 handleGlobalKeys(msg)
396 handleGlobalKeys(msg)
399 cmds = append(cmds, m.updateFocused(msg)...)
403// Draw implements [tea.Layer] and draws the UI model.
404func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
405 layout := generateLayout(m, area.Dx(), area.Dy())
407 if m.layout != layout {
412 // Clear the screen first
417 header := uv.NewStyledString(m.header)
418 header.Draw(scr, layout.header)
420 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
421 Height(layout.main.Dy()).
422 Background(lipgloss.ANSIColor(rand.Intn(256))).
423 Render(" Configure ")
424 main := uv.NewStyledString(mainView)
425 main.Draw(scr, layout.main)
428 header := uv.NewStyledString(m.header)
429 header.Draw(scr, layout.header)
431 main := uv.NewStyledString(m.initializeView())
432 main.Draw(scr, layout.main)
435 header := uv.NewStyledString(m.header)
436 header.Draw(scr, layout.header)
437 main := uv.NewStyledString(m.landingView())
438 main.Draw(scr, layout.main)
440 editor := uv.NewStyledString(m.textarea.View())
441 editor.Draw(scr, layout.editor)
444 m.chat.Draw(scr, layout.main)
446 header := uv.NewStyledString(m.header)
447 header.Draw(scr, layout.header)
448 m.drawSidebar(scr, layout.sidebar)
450 editor := uv.NewStyledString(m.textarea.View())
451 editor.Draw(scr, layout.editor)
454 header := uv.NewStyledString(m.header)
455 header.Draw(scr, layout.header)
457 mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
458 Height(layout.main.Dy()).
459 Background(lipgloss.ANSIColor(rand.Intn(256))).
460 Render(" Compact Chat Messages ")
461 main := uv.NewStyledString(mainView)
462 main.Draw(scr, layout.main)
464 editor := uv.NewStyledString(m.textarea.View())
465 editor.Draw(scr, layout.editor)
469 help := uv.NewStyledString(m.help.View(m))
470 help.Draw(scr, layout.help)
472 // Debugging rendering (visually see when the tui rerenders)
473 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
474 debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2)
475 debug := uv.NewStyledString(debugView.String())
476 debug.Draw(scr, image.Rectangle{
482 // This needs to come last to overlay on top of everything
483 if m.dialog.HasDialogs() {
484 if dialogView := m.dialog.View(); dialogView != "" {
485 dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView)
486 dialogArea := common.CenterRect(area, dialogWidth, dialogHeight)
487 dialog := uv.NewStyledString(dialogView)
488 dialog.Draw(scr, dialogArea)
493// Cursor returns the cursor position and properties for the UI model. It
494// returns nil if the cursor should not be shown.
495func (m *UI) Cursor() *tea.Cursor {
496 if m.layout.editor.Dy() <= 0 {
497 // Don't show cursor if editor is not visible
500 if m.focus == uiFocusEditor && m.textarea.Focused() {
501 cur := m.textarea.Cursor()
502 cur.X++ // Adjust for app margins
503 cur.Y += m.layout.editor.Min.Y
509// View renders the UI model's view.
510func (m *UI) View() tea.View {
513 v.BackgroundColor = m.com.Styles.Background
514 v.Cursor = m.Cursor()
515 v.MouseMode = tea.MouseModeCellMotion
517 canvas := uv.NewScreenBuffer(m.width, m.height)
518 m.Draw(canvas, canvas.Bounds())
520 content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
521 contentLines := strings.Split(content, "\n")
522 for i, line := range contentLines {
523 // Trim trailing spaces for concise rendering
524 contentLines[i] = strings.TrimRight(line, " ")
527 content = strings.Join(contentLines, "\n")
530 if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
531 // HACK: use a random percentage to prevent ghostty from hiding it
533 v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100))
539// ShortHelp implements [help.KeyMap].
540func (m *UI) ShortHelp() []key.Binding {
541 var binds []key.Binding
546 binds = append(binds, k.Quit)
548 // TODO: other states
549 // if m.session == nil {
550 // no session selected
551 binds = append(binds,
566 // binds = append(binds,
568 // k.Editor.SendMessage,
569 // k.Editor.OpenEditor,
573 // if len(m.attachments) > 0 {
574 // binds = append(binds,
575 // k.Editor.AttachmentDeleteMode,
576 // k.Editor.DeleteAllAttachments,
586// FullHelp implements [help.KeyMap].
587func (m *UI) FullHelp() [][]key.Binding {
588 var binds [][]key.Binding
591 help.SetHelp("ctrl+g", "less")
595 binds = append(binds,
600 if m.session == nil {
601 // no session selected
602 binds = append(binds,
611 k.Editor.MentionFile,
627 // binds = append(binds, m.ShortHelp())
633// updateDialogs updates the dialog overlay with the given message and returns cmds
634func (m *UI) updateDialogs(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
635 updatedDialog, cmd := m.dialog.Update(msg)
636 m.dialog = updatedDialog
637 cmds = append(cmds, cmd)
641// updateFocused updates the focused model (chat or editor) with the given message
642// and appends any resulting commands to the cmds slice.
643func (m *UI) updateFocused(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
648 return append(cmds, m.updateInitializeView(msg)...)
649 case uiChat, uiLanding, uiChatCompact:
654 case key.Matches(msg, m.keyMap.Editor.Newline):
655 m.textarea.InsertRune('\n')
658 ta, cmd := m.textarea.Update(msg)
660 cmds = append(cmds, cmd)
667// updateLayoutAndSize updates the layout and sizes of UI components.
668func (m *UI) updateLayoutAndSize() {
669 m.layout = generateLayout(m, m.width, m.height)
673// updateSize updates the sizes of UI components based on the current layout.
674func (m *UI) updateSize() {
676 m.help.SetWidth(m.layout.help.Dx())
678 m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy())
679 m.textarea.SetWidth(m.layout.editor.Dx())
680 m.textarea.SetHeight(m.layout.editor.Dy())
682 // Handle different app states
684 case uiConfigure, uiInitialize, uiLanding:
685 m.renderHeader(false, m.layout.header.Dx())
688 m.renderSidebarLogo(m.layout.sidebar.Dx())
691 // TODO: set the width and heigh of the chat component
692 m.renderHeader(true, m.layout.header.Dx())
696// generateLayout calculates the layout rectangles for all UI components based
697// on the current UI state and terminal dimensions.
698func generateLayout(m *UI, w, h int) layout {
699 // The screen area we're working with
700 area := image.Rect(0, 0, w, h)
709 // TODO: handle compact
712 var helpKeyMap help.KeyMap = m
714 for _, row := range helpKeyMap.FullHelp() {
715 helpHeight = max(helpHeight, len(row))
726 if slices.Contains([]uiState{uiConfigure, uiInitialize, uiLanding}, m.state) {
727 // extra padding on left and right for these states
732 appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight))
739 // Handle different app states
741 case uiConfigure, uiInitialize:
750 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
751 layout.header = headerRect
752 layout.main = mainRect
764 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight))
765 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
766 // Remove extra padding from editor (but keep it for header and main)
767 editorRect.Min.X -= 1
768 editorRect.Max.X += 1
769 layout.header = headerRect
770 layout.main = mainRect
771 layout.editor = editorRect
783 mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth))
786 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
787 mainRect.Max.X -= 1 // Add padding right
788 // Add bottom margin to main
790 layout.sidebar = sideRect
791 layout.main = mainRect
792 layout.editor = editorRect
804 headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight))
805 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
806 layout.header = headerRect
807 layout.main = mainRect
808 layout.editor = editorRect
811 if !layout.editor.Empty() {
812 // Add editor margins 1 top and bottom
813 layout.editor.Min.Y += 1
814 layout.editor.Max.Y -= 1
820// layout defines the positioning of UI elements.
822 // area is the overall available area.
825 // header is the header shown in special cases
826 // e.x when the sidebar is collapsed
827 // or when in the landing page
831 // main is the area for the main pane. (e.x chat, configure, landing)
834 // editor is the area for the editor pane.
837 // sidebar is the area for the sidebar.
840 // help is the area for the help view.
844// setEditorPrompt configures the textarea prompt function based on whether
845// yolo mode is enabled.
846func (m *UI) setEditorPrompt() {
847 if m.com.App.Permissions.SkipRequests() {
848 m.textarea.SetPromptFunc(4, m.yoloPromptFunc)
851 m.textarea.SetPromptFunc(4, m.normalPromptFunc)
854// normalPromptFunc returns the normal editor prompt style (" > " on first
855// line, "::: " on subsequent lines).
856func (m *UI) normalPromptFunc(info textarea.PromptInfo) string {
858 if info.LineNumber == 0 {
862 return t.EditorPromptNormalFocused.Render()
864 return t.EditorPromptNormalBlurred.Render()
867// yoloPromptFunc returns the yolo mode editor prompt style with warning icon
869func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string {
871 if info.LineNumber == 0 {
873 return t.EditorPromptYoloIconFocused.Render()
875 return t.EditorPromptYoloIconBlurred.Render()
879 return t.EditorPromptYoloDotsFocused.Render()
881 return t.EditorPromptYoloDotsBlurred.Render()
884var readyPlaceholders = [...]string{
888 "Ready for instructions",
891var workingPlaceholders = [...]string{
900// randomizePlaceholders selects random placeholder text for the textarea's
901// ready and working states.
902func (m *UI) randomizePlaceholders() {
903 m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))]
904 m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))]
907// renderHeader renders and caches the header logo at the specified width.
908func (m *UI) renderHeader(compact bool, width int) {
909 // TODO: handle the compact case differently
910 m.header = renderLogo(m.com.Styles, compact, width)
913// renderSidebarLogo renders and caches the sidebar logo at the specified
915func (m *UI) renderSidebarLogo(width int) {
916 m.sidebarLogo = renderLogo(m.com.Styles, true, width)
919// renderLogo renders the Crush logo with the given styles and dimensions.
920func renderLogo(t *styles.Styles, compact bool, width int) string {
921 return logo.Render(version.Version, compact, logo.Opts{
922 FieldColor: t.LogoFieldColor,
923 TitleColorA: t.LogoTitleColorA,
924 TitleColorB: t.LogoTitleColorB,
925 CharmColor: t.LogoCharmColor,
926 VersionColor: t.LogoVersionColor,
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list