From 063398841cb5bcca61d7a6b7d6a08cd8146b26be Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 2 Dec 2025 15:35:02 -0500 Subject: [PATCH] feat(ui): initial chat ui implementation --- internal/ui/common/markdown.go | 27 +++ internal/ui/list/list.go | 24 +-- internal/ui/model/chat.go | 331 +++++++++++++++++++++++++++------ internal/ui/model/keys.go | 29 +++ internal/ui/model/ui.go | 145 ++++++++++----- internal/ui/styles/styles.go | 42 ++++- 6 files changed, 472 insertions(+), 126 deletions(-) create mode 100644 internal/ui/common/markdown.go diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go new file mode 100644 index 0000000000000000000000000000000000000000..3c90c2dc1582160c919f4fe432e78642a0a2c97d --- /dev/null +++ b/internal/ui/common/markdown.go @@ -0,0 +1,27 @@ +package common + +import ( + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/glamour/v2" + gstyles "github.com/charmbracelet/glamour/v2/styles" +) + +// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with +// the given styles and width. +func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(t.Markdown), + glamour.WithWordWrap(width), + ) + return r +} + +// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors +// (plain text with structure) and the given width. +func PlainMarkdownRenderer(width int) *glamour.TermRenderer { + r, _ := glamour.NewTermRenderer( + glamour.WithStyles(gstyles.ASCIIStyleConfig), + glamour.WithWordWrap(width), + ) + return r +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 7b2c72ecfc35262bbf53c33c43fb859b5ceb3068..8928cb0f011fffe2e1f70c3a34b7a6bad6212f67 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -5,6 +5,7 @@ import ( uv "github.com/charmbracelet/ultraviolet" "github.com/charmbracelet/ultraviolet/screen" + "github.com/charmbracelet/x/exp/ordered" ) // List is a scrollable list component that implements uv.Drawable. @@ -160,7 +161,7 @@ func (l *List) renderViewport() string { for i := range lines { lines[i] = emptyLine } - return strings.Join(lines, "\r\n") + return strings.Join(lines, "\n") } if srcEndY > len(buf.Lines) { srcEndY = len(buf.Lines) @@ -182,7 +183,7 @@ func (l *List) renderViewport() string { lines[lineIdx] = emptyLine } - return strings.Join(lines, "\r\n") + return strings.Join(lines, "\n") } // drawViewport draws the visible portion from master buffer to target screen. @@ -199,18 +200,18 @@ func (l *List) drawViewport(scr uv.Screen, area uv.Rectangle) { srcEndY := l.offset + area.Dy() // Clamp to actual buffer bounds - if srcStartY >= len(buf.Lines) { + if srcStartY >= buf.Height() { screen.ClearArea(scr, area) return } - if srcEndY > len(buf.Lines) { - srcEndY = len(buf.Lines) + if srcEndY > buf.Height() { + srcEndY = buf.Height() } // Copy visible lines to target screen destY := area.Min.Y for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ { - line := buf.Lines[srcY] + line := buf.Line(srcY) destX := area.Min.X for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ { @@ -840,16 +841,7 @@ func (l *List) SelectedItemInView() bool { // clampOffset ensures offset is within valid bounds. func (l *List) clampOffset() { - maxOffset := l.totalHeight - l.height - if maxOffset < 0 { - maxOffset = 0 - } - - if l.offset < 0 { - l.offset = 0 - } else if l.offset > maxOffset { - l.offset = maxOffset - } + l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height) } // focusSelectedItem focuses the currently selected item if it's focusable. diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 74695624ce731b415f8c9a3f565f87541e62dfaf..28272d8b6356ebd7de39d888f6de886b1c2e3b0e 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,86 +1,295 @@ package model import ( - "charm.land/bubbles/v2/key" + "fmt" + "strings" + tea "charm.land/bubbletea/v2" - "github.com/charmbracelet/crush/internal/app" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" + uv "github.com/charmbracelet/ultraviolet" + "github.com/google/uuid" +) + +// ChatAnimItem represents a chat animation item in the chat UI. +type ChatAnimItem struct { + list.BaseFocusable + anim *anim.Anim +} + +var ( + _ list.Item = (*ChatAnimItem)(nil) + _ list.Focusable = (*ChatAnimItem)(nil) +) + +// NewChatAnimItem creates a new instance of [ChatAnimItem]. +func NewChatAnimItem(a *anim.Anim) *ChatAnimItem { + m := new(ChatAnimItem) + return m +} + +// Init initializes the chat animation item. +func (c *ChatAnimItem) Init() tea.Cmd { + return c.anim.Init() +} + +// Step advances the animation by one step. +func (c *ChatAnimItem) Step() tea.Cmd { + return c.anim.Step() +} + +// SetLabel sets the label for the animation item. +func (c *ChatAnimItem) SetLabel(label string) { + c.anim.SetLabel(label) +} + +// Draw implements list.Item. +func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) { + styled := uv.NewStyledString(c.anim.View()) + styled.Draw(scr, area) +} + +// Height implements list.Item. +func (c *ChatAnimItem) Height(int) int { + return 1 +} + +// ID implements list.Item. +func (c *ChatAnimItem) ID() string { + return "anim" +} + +// ChatNoContentItem represents a chat item with no content. +type ChatNoContentItem struct { + *list.StringItem +} + +// NewChatNoContentItem creates a new instance of [ChatNoContentItem]. +func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem { + c := new(ChatNoContentItem) + c.StringItem = list.NewStringItem(id, "No message content"). + WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage) + return c +} + +// ChatMessageItem represents a chat message item in the chat UI. +type ChatMessageItem struct { + list.BaseFocusable + list.BaseHighlightable + + item list.Item + msg message.Message +} + +var ( + _ list.Item = (*ChatMessageItem)(nil) + _ list.Focusable = (*ChatMessageItem)(nil) + _ list.Highlightable = (*ChatMessageItem)(nil) ) -// ChatKeyMap defines key bindings for the chat model. -type ChatKeyMap struct { - NewSession key.Binding - AddAttachment key.Binding - Cancel key.Binding - Tab key.Binding - Details key.Binding -} - -// DefaultChatKeyMap returns the default key bindings for the chat model. -func DefaultChatKeyMap() ChatKeyMap { - return ChatKeyMap{ - NewSession: key.NewBinding( - key.WithKeys("ctrl+n"), - key.WithHelp("ctrl+n", "new session"), - ), - AddAttachment: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "add attachment"), - ), - Cancel: key.NewBinding( - key.WithKeys("esc", "alt+esc"), - key.WithHelp("esc", "cancel"), - ), - Tab: key.NewBinding( - key.WithKeys("tab"), - key.WithHelp("tab", "change focus"), - ), - Details: key.NewBinding( - key.WithKeys("ctrl+d"), - key.WithHelp("ctrl+d", "toggle details"), - ), +// NewChatMessageItem creates a new instance of [ChatMessageItem]. +func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem { + c := new(ChatMessageItem) + + switch msg.Role { + case message.User: + item := list.NewMarkdownItem(msg.ID, msg.Content().String()). + WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred) + item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) + // TODO: Add attachments + c.item = item + default: + var thinkingContent string + content := msg.Content().String() + thinking := msg.IsThinking() + finished := msg.IsFinished() + finishedData := msg.FinishPart() + reasoningContent := msg.ReasoningContent() + reasoningThinking := strings.TrimSpace(reasoningContent.Thinking) + + if finished && content == "" && finishedData.Reason == message.FinishReasonError { + tag := t.Chat.ErrorTag.Render("ERROR") + title := t.Chat.ErrorTitle.Render(finishedData.Message) + details := t.Chat.ErrorDetails.Render(finishedData.Details) + errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details) + + item := list.NewStringItem(msg.ID, errContent). + WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + + c.item = item + + return c + } + + if thinking || reasoningThinking != "" { + // TODO: animation item? + // TODO: thinking item + thinkingContent = reasoningThinking + } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled { + content = "*Canceled*" + } + + var parts []string + if thinkingContent != "" { + parts = append(parts, thinkingContent) + } + + if content != "" { + if len(parts) > 0 { + parts = append(parts, "") + } + parts = append(parts, content) + } + + item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")). + WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred) + item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection)) + + c.item = item } + + return c +} + +// Draw implements list.Item. +func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) { + c.item.Draw(scr, area) +} + +// Height implements list.Item. +func (c *ChatMessageItem) Height(width int) int { + return c.item.Height(width) +} + +// ID implements list.Item. +func (c *ChatMessageItem) ID() string { + return c.item.ID() +} + +// Chat represents the chat UI model that handles chat interactions and +// messages. +type Chat struct { + com *common.Common + list *list.List +} + +// NewChat creates a new instance of [Chat] that handles chat interactions and +// messages. +func NewChat(com *common.Common) *Chat { + l := list.New() + return &Chat{ + com: com, + list: l, + } +} + +// Height returns the height of the chat view port. +func (m *Chat) Height() int { + return m.list.Height() +} + +// Draw renders the chat UI component to the screen and the given area. +func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) { + m.list.Draw(scr, area) +} + +// SetSize sets the size of the chat view port. +func (m *Chat) SetSize(width, height int) { + m.list.SetSize(width, height) +} + +// Len returns the number of items in the chat list. +func (m *Chat) Len() int { + return m.list.Len() } -// ChatModel represents the chat UI model. -type ChatModel struct { - app *app.App - com *common.Common +// PrependItem prepends a new item to the chat list. +func (m *Chat) PrependItem(item list.Item) { + m.list.PrependItem(item) +} - keyMap ChatKeyMap +// AppendMessage appends a new message item to the chat list. +func (m *Chat) AppendMessage(msg message.Message) { + if msg.ID == "" { + m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString())) + } else { + m.AppendItem(NewChatMessageItem(m.com.Styles, msg)) + } } -// NewChatModel creates a new instance of ChatModel. -func NewChatModel(com *common.Common, app *app.App) *ChatModel { - return &ChatModel{ - app: app, - com: com, - keyMap: DefaultChatKeyMap(), +// AppendItem appends a new item to the chat list. +func (m *Chat) AppendItem(item list.Item) { + if m.Len() > 0 { + // Always add a spacer between messages + m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1)) } + m.list.AppendItem(item) +} + +// Focus sets the focus state of the chat component. +func (m *Chat) Focus() { + m.list.Focus() +} + +// Blur removes the focus state from the chat component. +func (m *Chat) Blur() { + m.list.Blur() +} + +// ScrollToTop scrolls the chat view to the top. +func (m *Chat) ScrollToTop() { + m.list.ScrollToTop() +} + +// ScrollToBottom scrolls the chat view to the bottom. +func (m *Chat) ScrollToBottom() { + m.list.ScrollToBottom() +} + +// ScrollBy scrolls the chat view by the given number of line deltas. +func (m *Chat) ScrollBy(lines int) { + m.list.ScrollBy(lines) +} + +// ScrollToSelected scrolls the chat view to the selected item. +func (m *Chat) ScrollToSelected() { + m.list.ScrollToSelected() +} + +// SelectedItemInView returns whether the selected item is currently in view. +func (m *Chat) SelectedItemInView() bool { + return m.list.SelectedItemInView() +} + +// SetSelectedIndex sets the selected message index in the chat list. +func (m *Chat) SetSelectedIndex(index int) { + m.list.SetSelectedIndex(index) } -// Init initializes the chat model. -func (m *ChatModel) Init() tea.Cmd { - return nil +// SelectPrev selects the previous message in the chat list. +func (m *Chat) SelectPrev() { + m.list.SelectPrev() } -// Update handles incoming messages and updates the chat model state. -func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) { - // Handle messages here - return m, nil +// SelectNext selects the next message in the chat list. +func (m *Chat) SelectNext() { + m.list.SelectNext() } -// View renders the chat model's view. -func (m *ChatModel) View() string { - return "Chat Model View" +// HandleMouseDown handles mouse down events for the chat component. +func (m *Chat) HandleMouseDown(x, y int) { + m.list.HandleMouseDown(x, y) } -// ShortHelp returns a brief help view for the chat model. -func (m *ChatModel) ShortHelp() []key.Binding { - return nil +// HandleMouseUp handles mouse up events for the chat component. +func (m *Chat) HandleMouseUp(x, y int) { + m.list.HandleMouseUp(x, y) } -// FullHelp returns a detailed help view for the chat model. -func (m *ChatModel) FullHelp() [][]key.Binding { - return nil +// HandleMouseDrag handles mouse drag events for the chat component. +func (m *Chat) HandleMouseDrag(x, y int) { + m.list.HandleMouseDrag(x, y) } diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index 4acf010512b64de707eb6716ba574c885604276d..143f1d623828b56baa3fd43b86ca74ea2fbd82b9 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -17,6 +17,14 @@ type KeyMap struct { DeleteAllAttachments key.Binding } + Chat struct { + NewSession key.Binding + AddAttachment key.Binding + Cancel key.Binding + Tab key.Binding + Details key.Binding + } + Initialize struct { Yes, No, @@ -106,6 +114,27 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Chat.NewSession = key.NewBinding( + key.WithKeys("ctrl+n"), + key.WithHelp("ctrl+n", "new session"), + ) + km.Chat.AddAttachment = key.NewBinding( + key.WithKeys("ctrl+f"), + key.WithHelp("ctrl+f", "add attachment"), + ) + km.Chat.Cancel = key.NewBinding( + key.WithKeys("esc", "alt+esc"), + key.WithHelp("esc", "cancel"), + ) + km.Chat.Tab = key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "change focus"), + ) + km.Chat.Details = key.NewBinding( + key.WithKeys("ctrl+d"), + key.WithHelp("ctrl+d", "toggle details"), + ) + km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), key.WithHelp("y", "yes"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 81475f35c616583bb098c3c570b627f5392cfb60..d11f25149c4bd7f97e0c256ee232042aed8a3634 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -2,7 +2,6 @@ package model import ( "context" - "fmt" "image" "math/rand" "os" @@ -22,7 +21,6 @@ import ( "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/list" "github.com/charmbracelet/crush/internal/ui/logo" "github.com/charmbracelet/crush/internal/ui/styles" "github.com/charmbracelet/crush/internal/version" @@ -77,7 +75,6 @@ type UI struct { keyMap KeyMap keyenh tea.KeyboardEnhancementsMsg - chat *list.List dialog *dialog.Overlay help help.Model @@ -100,6 +97,9 @@ type UI struct { readyPlaceholder string workingPlaceholder string + // Chat components + chat *Chat + // onboarding state onboarding struct { yesInitializeSelected bool @@ -113,6 +113,9 @@ type UI struct { // sidebarLogo keeps a cached version of the sidebar sidebarLogo. sidebarLogo string + + // Canvas for rendering + canvas *uv.ScreenBuffer } // New creates a new instance of the [UI] model. @@ -125,7 +128,11 @@ func New(com *common.Common) *UI { ta.SetVirtualCursor(false) ta.Focus() - l := list.New() + ch := NewChat(com) + + // TODO: Switch to lipgloss.Canvas when available + canvas := uv.NewScreenBuffer(0, 0) + canvas.Method = ansi.GraphemeWidth ui := &UI{ com: com, @@ -135,7 +142,8 @@ func New(com *common.Common) *UI { focus: uiFocusNone, state: uiConfigure, textarea: ta, - chat: l, + chat: ch, + canvas: &canvas, } // set onboarding state defaults @@ -167,6 +175,10 @@ func (m *UI) Init() 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, m.loadSession(allSessions[0].ID)) + } return tea.Batch(cmds...) } @@ -182,6 +194,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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) + for _, message := range msgs { + m.chat.AppendMessage(message) + } + m.chat.ScrollToBottom() case sessionFilesLoadedMsg: m.sessionFiles = msg.files case pubsub.Event[history.File]: @@ -200,13 +218,53 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.updateLayoutAndSize() - m.chat.ScrollToBottom() + m.canvas.Resize(msg.Width, msg.Height) 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)...) } @@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { } switch { - case msg.String() == "ctrl+shift+t": - m.chat.SelectPrev() - case msg.String() == "ctrl+t": - m.focus = uiFocusMain - m.state = uiChat - if m.chat.Len() > 0 { - m.chat.AppendItem(list.Gap) - } - m.chat.AppendItem( - list.NewStringItem( - fmt.Sprintf("%d", m.chat.Len()), - fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)), - ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur), - ) - m.chat.SetSelectedIndex(m.chat.Len() - 1) - m.chat.Focus() - m.chat.ScrollToBottom() case key.Matches(msg, m.keyMap.Tab): switch m.state { case uiChat: 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.SetSelectedIndex(m.chat.Len() - 1) } } case key.Matches(msg, m.keyMap.Help): @@ -298,65 +342,72 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) { // 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, m.layout.header) + header.Draw(scr, layout.header) - mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). - Height(m.layout.main.Dy()). + 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, m.layout.main) + main.Draw(scr, layout.main) case uiInitialize: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) main := uv.NewStyledString(m.initializeView()) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) case uiLanding: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) main := uv.NewStyledString(m.landingView()) - main.Draw(scr, m.layout.main) + main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) case uiChat: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) - m.drawSidebar(scr, m.layout.sidebar) + header.Draw(scr, layout.header) + m.drawSidebar(scr, layout.sidebar) - m.chat.Draw(scr, m.layout.main) + m.chat.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) case uiChatCompact: header := uv.NewStyledString(m.header) - header.Draw(scr, m.layout.header) + header.Draw(scr, layout.header) - mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()). - Height(m.layout.main.Dy()). + 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, m.layout.main) + main.Draw(scr, layout.main) editor := uv.NewStyledString(m.textarea.View()) - editor.Draw(scr, m.layout.editor) + editor.Draw(scr, layout.editor) } // Add help layer help := uv.NewStyledString(m.help.View(m)) - help.Draw(scr, m.layout.help) + help.Draw(scr, layout.help) // Debugging rendering (visually see when the tui rerenders) if os.Getenv("CRUSH_UI_DEBUG") == "true" { @@ -401,14 +452,11 @@ func (m *UI) View() tea.View { v.AltScreen = true v.BackgroundColor = m.com.Styles.Background v.Cursor = m.Cursor() + v.MouseMode = tea.MouseModeCellMotion - // TODO: Switch to lipgloss.Canvas when available - canvas := uv.NewScreenBuffer(m.width, m.height) - canvas.Method = ansi.GraphemeWidth - - m.Draw(canvas, canvas.Bounds()) + m.Draw(m.canvas, m.canvas.Bounds()) - content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines + content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines contentLines := strings.Split(content, "\n") for i, line := range contentLines { // Trim trailing spaces for concise rendering @@ -680,6 +728,7 @@ func generateLayout(m *UI, w, h int) layout { // 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 diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 049652225920098622e0988c8d073d5e95527d50..59be32af0deabfcfd749f72edc7d4493b8ed8870 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -151,6 +151,20 @@ type Styles struct { Additions lipgloss.Style Deletions lipgloss.Style } + + // Chat + Chat struct { + UserMessageBlurred lipgloss.Style + UserMessageFocused lipgloss.Style + AssistantMessageBlurred lipgloss.Style + AssistantMessageFocused lipgloss.Style + NoContentMessage lipgloss.Style + ThinkingMessage lipgloss.Style + + ErrorTag lipgloss.Style + ErrorTitle lipgloss.Style + ErrorDetails lipgloss.Style + } } func DefaultStyles() Styles { @@ -194,12 +208,14 @@ func DefaultStyles() Styles { greenDark = charmtone.Guac // greenLight = charmtone.Bok - // red = charmtone.Coral + red = charmtone.Coral redDark = charmtone.Sriracha // redLight = charmtone.Salmon // cherry = charmtone.Cherry ) + normalBorder := lipgloss.NormalBorder() + base := lipgloss.NewStyle().Foreground(fgBase) s := Styles{} @@ -607,9 +623,33 @@ func DefaultStyles() Styles { s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted) s.LSP.InfoDiagnostic = s.Base.Foreground(info) + // Files s.Files.Path = s.Muted s.Files.Additions = s.Base.Foreground(greenDark) s.Files.Deletions = s.Base.Foreground(redDark) + + // Chat + messageFocussedBorder := lipgloss.Border{ + Left: "▌", + } + + s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase) + s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(normalBorder) + s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(primary).BorderStyle(messageFocussedBorder) + s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2) + s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true). + BorderForeground(greenDark).BorderStyle(messageFocussedBorder) + s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10) + s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1). + Background(red).Foreground(white) + s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted) + s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle) + + // Text selection. + s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple) + return s }