package model import ( "context" "image" "math/rand" "os" "slices" "strings" "time" "charm.land/bubbles/v2/help" "charm.land/bubbles/v2/key" "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/history" "github.com/charmbracelet/crush/internal/message" "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" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" ) // uiFocusState represents the current focus state of the UI. type uiFocusState uint8 // Possible uiFocusState values. const ( uiFocusNone uiFocusState = iota uiFocusEditor uiFocusMain ) type uiState uint8 // Possible uiState values. const ( uiConfigure uiState = iota uiInitialize uiLanding uiChat uiChatCompact ) type sessionLoadedMsg struct { sess session.Session } type sessionFilesLoadedMsg struct { files []SessionFile } // UI represents the main user interface model. type UI struct { com *common.Common session *session.Session sessionFiles []SessionFile // The width and height of the terminal in cells. width int height int layout layout focus uiFocusState state uiState keyMap KeyMap keyenh tea.KeyboardEnhancementsMsg dialog *dialog.Overlay help help.Model // header is the last cached header logo header string // sendProgressBar instructs the TUI to send progress bar updates to the // terminal. sendProgressBar bool // QueryVersion instructs the TUI to query for the terminal version when it // starts. QueryVersion bool // Editor components textarea textarea.Model attachments []any // TODO: Implement attachments readyPlaceholder string workingPlaceholder string // Chat components chat *Chat // onboarding state onboarding struct { yesInitializeSelected bool } // lsp lspStates map[string]app.LSPClientInfo // mcp mcpStates map[string]mcp.ClientInfo // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string } // New creates a new instance of the [UI] model. func New(com *common.Common) *UI { // Editor components ta := textarea.New() ta.SetStyles(com.Styles.TextArea) ta.ShowLineNumbers = false ta.CharLimit = -1 ta.SetVirtualCursor(false) ta.Focus() ch := NewChat(com) ui := &UI{ com: com, dialog: dialog.NewOverlay(), keyMap: DefaultKeyMap(), help: help.New(), focus: uiFocusNone, state: uiConfigure, textarea: ta, chat: ch, } // 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 // if the project needs initialization show the user the question } else if n, _ := config.ProjectNeedsInitialization(); n { ui.state = uiInitialize // otherwise go to the landing UI } else { ui.state = uiLanding ui.focus = uiFocusEditor } ui.setEditorPrompt() ui.randomizePlaceholders() ui.textarea.Placeholder = ui.readyPlaceholder ui.help.Styles = com.Styles.Help return ui } // Init initializes the UI model. func (m *UI) Init() tea.Cmd { var cmds []tea.Cmd if m.QueryVersion { cmds = append(cmds, tea.RequestTerminalVersion) } allSessions, _ := m.com.App.Sessions.List(context.Background()) if len(allSessions) > 0 { cmds = append(cmds, func() tea.Msg { time.Sleep(2 * time.Second) return m.loadSession(allSessions[1].ID)() }) } return tea.Batch(cmds...) } // sessionLoadedDoneMsg indicates that session loading and message appending is // done. type sessionLoadedDoneMsg struct{} // Update handles updates to the UI model. func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.EnvMsg: // Is this Windows Terminal? if !m.sendProgressBar { m.sendProgressBar = slices.Contains(msg, "WT_SESSION") } case sessionLoadedMsg: m.state = uiChat m.session = &msg.sess // Load the last 20 messages from this session. msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID) // Build tool result map to link tool calls with their results msgPtrs := make([]*message.Message, len(msgs)) for i := range msgs { msgPtrs[i] = &msgs[i] } toolResultMap := BuildToolResultMap(msgPtrs) // Add messages to chat with linked tool results items := make([]MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { items = append(items, GetMessageItems(msg, toolResultMap)...) } m.chat.AppendMessages(items...) // Notify that session loading is done to scroll to bottom. This is // needed because we need to draw the chat list first before we can // scroll to bottom. cmds = append(cmds, func() tea.Msg { return sessionLoadedDoneMsg{} }) case sessionLoadedDoneMsg: m.chat.ScrollToBottom() m.chat.SelectLast() case sessionFilesLoadedMsg: m.sessionFiles = msg.files case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) 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. if !m.sendProgressBar { m.sendProgressBar = strings.Contains(termVersion, "ghostty") } return m, nil case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() case tea.KeyboardEnhancementsMsg: m.keyenh = msg if msg.SupportsKeyDisambiguation() { m.keyMap.Models.SetHelp("ctrl+m", "models") m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline") } case tea.MouseClickMsg: switch m.state { case uiChat: m.chat.HandleMouseDown(msg.X, msg.Y) } case tea.MouseMotionMsg: switch m.state { case uiChat: if msg.Y <= 0 { m.chat.ScrollBy(-1) } else if msg.Y >= m.chat.Height()-1 { m.chat.ScrollBy(1) } m.chat.HandleMouseDrag(msg.X, msg.Y) } case tea.MouseReleaseMsg: switch m.state { case uiChat: m.chat.HandleMouseUp(msg.X, msg.Y) } case tea.MouseWheelMsg: switch m.state { case uiChat: switch msg.Button { case tea.MouseWheelUp: m.chat.ScrollBy(-5) if !m.chat.SelectedItemInView() { m.chat.SelectPrev() m.chat.ScrollToSelected() } case tea.MouseWheelDown: m.chat.ScrollBy(5) if !m.chat.SelectedItemInView() { m.chat.SelectNext() m.chat.ScrollToSelected() } } } case tea.KeyPressMsg: cmds = append(cmds, m.handleKeyPressMsg(msg)...) } // 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!" } } return m, tea.Batch(cmds...) } func (m *UI) loadSession(sessionID string) tea.Cmd { return func() tea.Msg { // TODO: handle error session, _ := m.com.App.Sessions.Get(context.Background(), sessionID) return sessionLoadedMsg{session} } } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { if m.dialog.HasDialogs() { return m.updateDialogs(msg) } handleGlobalKeys := func(msg tea.KeyPressMsg) { switch { case key.Matches(msg, m.keyMap.Tab): 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 } 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 } } switch m.state { case uiChat: switch { case key.Matches(msg, m.keyMap.Tab): if m.focus == uiFocusMain { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() } else { m.focus = uiFocusMain m.textarea.Blur() m.chat.Focus() m.chat.SetSelected(m.chat.Len() - 1) } case key.Matches(msg, m.keyMap.Chat.Up): m.chat.ScrollBy(-1) if !m.chat.SelectedItemInView() { m.chat.SelectPrev() m.chat.ScrollToSelected() } case key.Matches(msg, m.keyMap.Chat.Down): m.chat.ScrollBy(1) if !m.chat.SelectedItemInView() { m.chat.SelectNext() m.chat.ScrollToSelected() } case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() m.chat.ScrollToSelected() case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() m.chat.ScrollToSelected() case key.Matches(msg, m.keyMap.Chat.HalfPageUp): m.chat.ScrollBy(-m.chat.Height() / 2) m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.HalfPageDown): m.chat.ScrollBy(m.chat.Height() / 2) m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.PageUp): m.chat.ScrollBy(-m.chat.Height()) m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.PageDown): m.chat.ScrollBy(m.chat.Height()) m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.Home): m.chat.ScrollToTop() m.chat.SelectFirst() case key.Matches(msg, m.keyMap.Chat.End): m.chat.ScrollToBottom() m.chat.SelectLast() default: handleGlobalKeys(msg) } default: handleGlobalKeys(msg) } cmds = append(cmds, m.updateFocused(msg)...) return cmds } // Draw implements [tea.Layer] and draws the UI model. func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) { layout := generateLayout(m, area.Dx(), area.Dy()) if m.layout != layout { m.layout = layout m.updateSize() } // Clear the screen first screen.Clear(scr) switch m.state { case uiConfigure: 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(" Configure ") main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) case uiInitialize: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) main := uv.NewStyledString(m.initializeView()) main.Draw(scr, layout.main) case uiLanding: header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) main := uv.NewStyledString(m.landingView()) main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) editor.Draw(scr, layout.editor) case uiChat: m.chat.Draw(scr, layout.main) header := uv.NewStyledString(m.header) header.Draw(scr, layout.header) m.drawSidebar(scr, layout.sidebar) editor := uv.NewStyledString(m.textarea.View()) editor.Draw(scr, layout.editor) case uiChatCompact: 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(" Compact Chat Messages ") main := uv.NewStyledString(mainView) main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) editor.Draw(scr, layout.editor) } // Add help layer help := uv.NewStyledString(m.help.View(m)) help.Draw(scr, layout.help) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { debugView := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) debug := uv.NewStyledString(debugView.String()) debug.Draw(scr, image.Rectangle{ Min: image.Pt(4, 1), Max: image.Pt(8, 3), }) } // This needs to come last to overlay on top of everything if m.dialog.HasDialogs() { if dialogView := m.dialog.View(); dialogView != "" { dialogWidth, dialogHeight := lipgloss.Width(dialogView), lipgloss.Height(dialogView) dialogArea := common.CenterRect(area, dialogWidth, dialogHeight) dialog := uv.NewStyledString(dialogView) dialog.Draw(scr, dialogArea) } } } // Cursor returns the cursor position and properties for the UI model. It // returns nil if the cursor should not be shown. func (m *UI) Cursor() *tea.Cursor { if m.layout.editor.Dy() <= 0 { // Don't show cursor if editor is not visible return nil } if m.focus == uiFocusEditor && m.textarea.Focused() { cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += m.layout.editor.Min.Y return cur } return nil } // View renders the UI model's view. func (m *UI) View() tea.View { var v tea.View v.AltScreen = true v.BackgroundColor = m.com.Styles.Background v.Cursor = m.Cursor() v.MouseMode = tea.MouseModeCellMotion canvas := uv.NewScreenBuffer(m.width, m.height) m.Draw(canvas, canvas.Bounds()) content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines contentLines := strings.Split(content, "\n") for i, line := range contentLines { // Trim trailing spaces for concise rendering contentLines[i] = strings.TrimRight(line, " ") } content = strings.Join(contentLines, "\n") v.Content = content if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it // after a timeout. v.ProgressBar = tea.NewProgressBar(tea.ProgressBarIndeterminate, rand.Intn(100)) } return v } // ShortHelp implements [help.KeyMap]. func (m *UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap switch m.state { case uiInitialize: binds = append(binds, k.Quit) default: // TODO: other states // if m.session == nil { // no session selected binds = append(binds, k.Commands, k.Models, k.Editor.Newline, k.Quit, k.Help, ) // } // else { // we have a session // } // switch m.state { // case uiChat: // case uiEdit: // binds = append(binds, // k.Editor.AddFile, // k.Editor.SendMessage, // k.Editor.OpenEditor, // k.Editor.Newline, // ) // // if len(m.attachments) > 0 { // binds = append(binds, // k.Editor.AttachmentDeleteMode, // k.Editor.DeleteAllAttachments, // k.Editor.Escape, // ) // } // } } return binds } // FullHelp implements [help.KeyMap]. func (m *UI) FullHelp() [][]key.Binding { var binds [][]key.Binding k := &m.keyMap help := k.Help help.SetHelp("ctrl+g", "less") switch m.state { case uiInitialize: binds = append(binds, []key.Binding{ k.Quit, }) default: if m.session == nil { // no session selected binds = append(binds, []key.Binding{ k.Commands, k.Models, k.Sessions, }, []key.Binding{ k.Editor.Newline, k.Editor.AddImage, k.Editor.MentionFile, k.Editor.OpenEditor, }, []key.Binding{ help, }, ) } // else { // we have a session // } } // switch m.state { // case uiChat: // case uiEdit: // binds = append(binds, m.ShortHelp()) // } return binds } // 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 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.state { case uiConfigure: return cmds case uiInitialize: return append(cmds, m.updateInitializeView(msg)...) case uiChat, uiLanding, uiChatCompact: switch m.focus { case uiFocusMain: case uiFocusEditor: switch { case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') } ta, cmd := m.textarea.Update(msg) m.textarea = ta cmds = append(cmds, cmd) return cmds } } return cmds } // updateLayoutAndSize updates the layout and sizes of UI components. func (m *UI) updateLayoutAndSize() { m.layout = generateLayout(m, m.width, m.height) m.updateSize() } // updateSize updates the sizes of UI components based on the current layout. func (m *UI) updateSize() { // Set help width m.help.SetWidth(m.layout.help.Dx()) m.chat.SetSize(m.layout.main.Dx(), m.layout.main.Dy()) m.textarea.SetWidth(m.layout.editor.Dx()) m.textarea.SetHeight(m.layout.editor.Dy()) // Handle different app states switch m.state { case uiConfigure, uiInitialize, uiLanding: m.renderHeader(false, m.layout.header.Dx()) case uiChat: m.renderSidebarLogo(m.layout.sidebar.Dx()) case uiChatCompact: // TODO: set the width and heigh of the chat component m.renderHeader(true, m.layout.header.Dx()) } } // generateLayout calculates the layout rectangles for all UI components based // on the current UI state and terminal dimensions. func generateLayout(m *UI, w, h int) layout { // The screen area we're working with area := image.Rect(0, 0, w, h) // The help height helpHeight := 1 // The editor height editorHeight := 5 // The sidebar width sidebarWidth := 30 // The header height // TODO: handle compact headerHeight := 4 var helpKeyMap help.KeyMap = m if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) } } // Add app margins appRect := area appRect.Min.X += 1 appRect.Min.Y += 1 appRect.Max.X -= 1 appRect.Max.Y -= 1 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 } appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight)) layout := layout{ area: area, help: helpRect, } // Handle different app states switch m.state { case uiConfigure, uiInitialize: // Layout // // header // ------ // main // ------ // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) layout.header = headerRect layout.main = mainRect case uiLanding: // Layout // // header // ------ // main // ------ // editor // ------ // 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 case uiChat: // Layout // // ------|--- // main | // ------| side // editor| // ---------- // help mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) // Add padding left sideRect.Min.X += 1 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) mainRect.Max.X -= 1 // Add padding right // Add bottom margin to main mainRect.Max.Y -= 1 layout.sidebar = sideRect layout.main = mainRect layout.editor = editorRect case uiChatCompact: // Layout // // compact-header // ------ // main // ------ // editor // ------ // help headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight)) mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) layout.header = headerRect layout.main = mainRect layout.editor = editorRect } if !layout.editor.Empty() { // Add editor margins 1 top and bottom layout.editor.Min.Y += 1 layout.editor.Max.Y -= 1 } return layout } // layout defines the positioning of UI elements. type layout struct { // area is the overall available area. area uv.Rectangle // header is the header shown in special cases // e.x when the sidebar is collapsed // or when in the landing page // or in init/config header uv.Rectangle // main is the area for the main pane. (e.x chat, configure, landing) main uv.Rectangle // editor is the area for the editor pane. editor uv.Rectangle // sidebar is the area for the sidebar. sidebar uv.Rectangle // help is the area for the help view. help uv.Rectangle } // setEditorPrompt configures the textarea prompt function based on whether // yolo mode is enabled. func (m *UI) setEditorPrompt() { if m.com.App.Permissions.SkipRequests() { m.textarea.SetPromptFunc(4, m.yoloPromptFunc) return } m.textarea.SetPromptFunc(4, m.normalPromptFunc) } // normalPromptFunc returns the normal editor prompt style (" > " on first // line, "::: " on subsequent lines). func (m *UI) normalPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { return " > " } if info.Focused { return t.EditorPromptNormalFocused.Render() } return t.EditorPromptNormalBlurred.Render() } // yoloPromptFunc returns the yolo mode editor prompt style with warning icon // and colored dots. func (m *UI) yoloPromptFunc(info textarea.PromptInfo) string { t := m.com.Styles if info.LineNumber == 0 { if info.Focused { return t.EditorPromptYoloIconFocused.Render() } else { return t.EditorPromptYoloIconBlurred.Render() } } if info.Focused { return t.EditorPromptYoloDotsFocused.Render() } return t.EditorPromptYoloDotsBlurred.Render() } var readyPlaceholders = [...]string{ "Ready!", "Ready...", "Ready?", "Ready for instructions", } var workingPlaceholders = [...]string{ "Working!", "Working...", "Brrrrr...", "Prrrrrrrr...", "Processing...", "Thinking...", } // randomizePlaceholders selects random placeholder text for the textarea's // ready and working states. func (m *UI) randomizePlaceholders() { m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } // renderHeader renders and caches the header logo at the specified width. func (m *UI) renderHeader(compact bool, width int) { // TODO: handle the compact case differently m.header = renderLogo(m.com.Styles, compact, width) } // renderSidebarLogo renders and caches the sidebar logo at the specified // width. func (m *UI) renderSidebarLogo(width int) { m.sidebarLogo = renderLogo(m.com.Styles, true, width) } // renderLogo renders the Crush logo with the given styles and dimensions. func renderLogo(t *styles.Styles, compact bool, width int) string { return logo.Render(version.Version, compact, logo.Opts{ FieldColor: t.LogoFieldColor, TitleColorA: t.LogoTitleColorA, TitleColorB: t.LogoTitleColorB, CharmColor: t.LogoCharmColor, VersionColor: t.LogoVersionColor, Width: width, }) }