Detailed changes
@@ -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
+}
@@ -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.
@@ -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)
}
@@ -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"),
@@ -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
@@ -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
}