on main help sign in
feat(ui): initial chat ui implementation
commit063398841cb5bcca61d7a6b7d6a08cd8146b26be authorAyman Bagabas <ayman.bagabas@gmail.com> dateTue 2 Dec 2025, 15:35 (1 year ago) parent71a89b3
6 files changed+472−126
internal/ui/common/markdown.go+27 internal/ui/list/list.go+8−16 internal/ui/model/chat.go+270−61 internal/ui/model/keys.go+29 internal/ui/model/ui.go+97−48 internal/ui/styles/styles.go+41−1
internal/ui/common/markdown.gonew file+27
@@ -0,0 +1,27 @@
1+package common
2+
3+import (
4+ "github.com/charmbracelet/crush/internal/ui/styles"
5+ "github.com/charmbracelet/glamour/v2"
6+ gstyles "github.com/charmbracelet/glamour/v2/styles"
7+)
8+
9+// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
10+// the given styles and width.
11+func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
12+ r, _ := glamour.NewTermRenderer(
13+ glamour.WithStyles(t.Markdown),
14+ glamour.WithWordWrap(width),
15+ )
16+ return r
17+}
18+
19+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
20+// (plain text with structure) and the given width.
21+func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
22+ r, _ := glamour.NewTermRenderer(
23+ glamour.WithStyles(gstyles.ASCIIStyleConfig),
24+ glamour.WithWordWrap(width),
25+ )
26+ return r
27+}
internal/ui/list/list.go+8−16
@@ -5,6 +5,7 @@ import (
55
66 uv "github.com/charmbracelet/ultraviolet"
77 "github.com/charmbracelet/ultraviolet/screen"
8+ "github.com/charmbracelet/x/exp/ordered"
89)
910
1011// List is a scrollable list component that implements uv.Drawable.
@@ -160,7 +161,7 @@ func (l *List) renderViewport() string {
160161 for i := range lines {
161162 lines[i] = emptyLine
162163 }
163− return strings.Join(lines, "\r\n")
164+ return strings.Join(lines, "\n")
164165 }
165166 if srcEndY > len(buf.Lines) {
166167 srcEndY = len(buf.Lines)
@@ -182,7 +183,7 @@ func (l *List) renderViewport() string {
182183 lines[lineIdx] = emptyLine
183184 }
184185
185− return strings.Join(lines, "\r\n")
186+ return strings.Join(lines, "\n")
186187}
187188
188189// 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) {
199200 srcEndY := l.offset + area.Dy()
200201
201202 // Clamp to actual buffer bounds
202− if srcStartY >= len(buf.Lines) {
203+ if srcStartY >= buf.Height() {
203204 screen.ClearArea(scr, area)
204205 return
205206 }
206− if srcEndY > len(buf.Lines) {
207− srcEndY = len(buf.Lines)
207+ if srcEndY > buf.Height() {
208+ srcEndY = buf.Height()
208209 }
209210
210211 // Copy visible lines to target screen
211212 destY := area.Min.Y
212213 for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
213− line := buf.Lines[srcY]
214+ line := buf.Line(srcY)
214215 destX := area.Min.X
215216
216217 for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
@@ -840,16 +841,7 @@ func (l *List) SelectedItemInView() bool {
840841
841842// clampOffset ensures offset is within valid bounds.
842843func (l *List) clampOffset() {
843− maxOffset := l.totalHeight - l.height
844− if maxOffset < 0 {
845− maxOffset = 0
846− }
847−
848− if l.offset < 0 {
849− l.offset = 0
850− } else if l.offset > maxOffset {
851− l.offset = maxOffset
852− }
844+ l.offset = ordered.Clamp(l.offset, 0, l.totalHeight-l.height)
853845}
854846
855847// focusSelectedItem focuses the currently selected item if it's focusable.
internal/ui/model/chat.go+270−61
@@ -1,86 +1,295 @@
11package model
22
33import (
4− "charm.land/bubbles/v2/key"
4+ "fmt"
5+ "strings"
6+
57 tea "charm.land/bubbletea/v2"
6− "github.com/charmbracelet/crush/internal/app"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/tui/components/anim"
710 "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/list"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ uv "github.com/charmbracelet/ultraviolet"
14+ "github.com/google/uuid"
15+)
16+
17+// ChatAnimItem represents a chat animation item in the chat UI.
18+type ChatAnimItem struct {
19+ list.BaseFocusable
20+ anim *anim.Anim
21+}
22+
23+var (
24+ _ list.Item = (*ChatAnimItem)(nil)
25+ _ list.Focusable = (*ChatAnimItem)(nil)
26+)
27+
28+// NewChatAnimItem creates a new instance of [ChatAnimItem].
29+func NewChatAnimItem(a *anim.Anim) *ChatAnimItem {
30+ m := new(ChatAnimItem)
31+ return m
32+}
33+
34+// Init initializes the chat animation item.
35+func (c *ChatAnimItem) Init() tea.Cmd {
36+ return c.anim.Init()
37+}
38+
39+// Step advances the animation by one step.
40+func (c *ChatAnimItem) Step() tea.Cmd {
41+ return c.anim.Step()
42+}
43+
44+// SetLabel sets the label for the animation item.
45+func (c *ChatAnimItem) SetLabel(label string) {
46+ c.anim.SetLabel(label)
47+}
48+
49+// Draw implements list.Item.
50+func (c *ChatAnimItem) Draw(scr uv.Screen, area uv.Rectangle) {
51+ styled := uv.NewStyledString(c.anim.View())
52+ styled.Draw(scr, area)
53+}
54+
55+// Height implements list.Item.
56+func (c *ChatAnimItem) Height(int) int {
57+ return 1
58+}
59+
60+// ID implements list.Item.
61+func (c *ChatAnimItem) ID() string {
62+ return "anim"
63+}
64+
65+// ChatNoContentItem represents a chat item with no content.
66+type ChatNoContentItem struct {
67+ *list.StringItem
68+}
69+
70+// NewChatNoContentItem creates a new instance of [ChatNoContentItem].
71+func NewChatNoContentItem(t *styles.Styles, id string) *ChatNoContentItem {
72+ c := new(ChatNoContentItem)
73+ c.StringItem = list.NewStringItem(id, "No message content").
74+ WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
75+ return c
76+}
77+
78+// ChatMessageItem represents a chat message item in the chat UI.
79+type ChatMessageItem struct {
80+ list.BaseFocusable
81+ list.BaseHighlightable
82+
83+ item list.Item
84+ msg message.Message
85+}
86+
87+var (
88+ _ list.Item = (*ChatMessageItem)(nil)
89+ _ list.Focusable = (*ChatMessageItem)(nil)
90+ _ list.Highlightable = (*ChatMessageItem)(nil)
891)
992
10−// ChatKeyMap defines key bindings for the chat model.
11−type ChatKeyMap struct {
12− NewSession key.Binding
13− AddAttachment key.Binding
14− Cancel key.Binding
15− Tab key.Binding
16− Details key.Binding
17−}
18−
19−// DefaultChatKeyMap returns the default key bindings for the chat model.
20−func DefaultChatKeyMap() ChatKeyMap {
21− return ChatKeyMap{
22− NewSession: key.NewBinding(
23− key.WithKeys("ctrl+n"),
24− key.WithHelp("ctrl+n", "new session"),
25− ),
26− AddAttachment: key.NewBinding(
27− key.WithKeys("ctrl+f"),
28− key.WithHelp("ctrl+f", "add attachment"),
29− ),
30− Cancel: key.NewBinding(
31− key.WithKeys("esc", "alt+esc"),
32− key.WithHelp("esc", "cancel"),
33− ),
34− Tab: key.NewBinding(
35− key.WithKeys("tab"),
36− key.WithHelp("tab", "change focus"),
37− ),
38− Details: key.NewBinding(
39− key.WithKeys("ctrl+d"),
40− key.WithHelp("ctrl+d", "toggle details"),
41− ),
93+// NewChatMessageItem creates a new instance of [ChatMessageItem].
94+func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem {
95+ c := new(ChatMessageItem)
96+
97+ switch msg.Role {
98+ case message.User:
99+ item := list.NewMarkdownItem(msg.ID, msg.Content().String()).
100+ WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
101+ item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
102+ // TODO: Add attachments
103+ c.item = item
104+ default:
105+ var thinkingContent string
106+ content := msg.Content().String()
107+ thinking := msg.IsThinking()
108+ finished := msg.IsFinished()
109+ finishedData := msg.FinishPart()
110+ reasoningContent := msg.ReasoningContent()
111+ reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
112+
113+ if finished && content == "" && finishedData.Reason == message.FinishReasonError {
114+ tag := t.Chat.ErrorTag.Render("ERROR")
115+ title := t.Chat.ErrorTitle.Render(finishedData.Message)
116+ details := t.Chat.ErrorDetails.Render(finishedData.Details)
117+ errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
118+
119+ item := list.NewStringItem(msg.ID, errContent).
120+ WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
121+
122+ c.item = item
123+
124+ return c
125+ }
126+
127+ if thinking || reasoningThinking != "" {
128+ // TODO: animation item?
129+ // TODO: thinking item
130+ thinkingContent = reasoningThinking
131+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
132+ content = "*Canceled*"
133+ }
134+
135+ var parts []string
136+ if thinkingContent != "" {
137+ parts = append(parts, thinkingContent)
138+ }
139+
140+ if content != "" {
141+ if len(parts) > 0 {
142+ parts = append(parts, "")
143+ }
144+ parts = append(parts, content)
145+ }
146+
147+ item := list.NewMarkdownItem(msg.ID, strings.Join(parts, "\n")).
148+ WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
149+ item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
150+
151+ c.item = item
42152 }
153+
154+ return c
155+}
156+
157+// Draw implements list.Item.
158+func (c *ChatMessageItem) Draw(scr uv.Screen, area uv.Rectangle) {
159+ c.item.Draw(scr, area)
160+}
161+
162+// Height implements list.Item.
163+func (c *ChatMessageItem) Height(width int) int {
164+ return c.item.Height(width)
165+}
166+
167+// ID implements list.Item.
168+func (c *ChatMessageItem) ID() string {
169+ return c.item.ID()
170+}
171+
172+// Chat represents the chat UI model that handles chat interactions and
173+// messages.
174+type Chat struct {
175+ com *common.Common
176+ list *list.List
177+}
178+
179+// NewChat creates a new instance of [Chat] that handles chat interactions and
180+// messages.
181+func NewChat(com *common.Common) *Chat {
182+ l := list.New()
183+ return &Chat{
184+ com: com,
185+ list: l,
186+ }
187+}
188+
189+// Height returns the height of the chat view port.
190+func (m *Chat) Height() int {
191+ return m.list.Height()
192+}
193+
194+// Draw renders the chat UI component to the screen and the given area.
195+func (m *Chat) Draw(scr uv.Screen, area uv.Rectangle) {
196+ m.list.Draw(scr, area)
197+}
198+
199+// SetSize sets the size of the chat view port.
200+func (m *Chat) SetSize(width, height int) {
201+ m.list.SetSize(width, height)
202+}
203+
204+// Len returns the number of items in the chat list.
205+func (m *Chat) Len() int {
206+ return m.list.Len()
43207}
44208
45−// ChatModel represents the chat UI model.
46−type ChatModel struct {
47− app *app.App
48− com *common.Common
209+// PrependItem prepends a new item to the chat list.
210+func (m *Chat) PrependItem(item list.Item) {
211+ m.list.PrependItem(item)
212+}
49213
50− keyMap ChatKeyMap
214+// AppendMessage appends a new message item to the chat list.
215+func (m *Chat) AppendMessage(msg message.Message) {
216+ if msg.ID == "" {
217+ m.AppendItem(NewChatNoContentItem(m.com.Styles, uuid.NewString()))
218+ } else {
219+ m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
220+ }
51221}
52222
53−// NewChatModel creates a new instance of ChatModel.
54−func NewChatModel(com *common.Common, app *app.App) *ChatModel {
55− return &ChatModel{
56− app: app,
57− com: com,
58− keyMap: DefaultChatKeyMap(),
223+// AppendItem appends a new item to the chat list.
224+func (m *Chat) AppendItem(item list.Item) {
225+ if m.Len() > 0 {
226+ // Always add a spacer between messages
227+ m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1))
59228 }
229+ m.list.AppendItem(item)
230+}
231+
232+// Focus sets the focus state of the chat component.
233+func (m *Chat) Focus() {
234+ m.list.Focus()
235+}
236+
237+// Blur removes the focus state from the chat component.
238+func (m *Chat) Blur() {
239+ m.list.Blur()
240+}
241+
242+// ScrollToTop scrolls the chat view to the top.
243+func (m *Chat) ScrollToTop() {
244+ m.list.ScrollToTop()
245+}
246+
247+// ScrollToBottom scrolls the chat view to the bottom.
248+func (m *Chat) ScrollToBottom() {
249+ m.list.ScrollToBottom()
250+}
251+
252+// ScrollBy scrolls the chat view by the given number of line deltas.
253+func (m *Chat) ScrollBy(lines int) {
254+ m.list.ScrollBy(lines)
255+}
256+
257+// ScrollToSelected scrolls the chat view to the selected item.
258+func (m *Chat) ScrollToSelected() {
259+ m.list.ScrollToSelected()
260+}
261+
262+// SelectedItemInView returns whether the selected item is currently in view.
263+func (m *Chat) SelectedItemInView() bool {
264+ return m.list.SelectedItemInView()
265+}
266+
267+// SetSelectedIndex sets the selected message index in the chat list.
268+func (m *Chat) SetSelectedIndex(index int) {
269+ m.list.SetSelectedIndex(index)
60270}
61271
62−// Init initializes the chat model.
63−func (m *ChatModel) Init() tea.Cmd {
64− return nil
272+// SelectPrev selects the previous message in the chat list.
273+func (m *Chat) SelectPrev() {
274+ m.list.SelectPrev()
65275}
66276
67−// Update handles incoming messages and updates the chat model state.
68−func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) {
69− // Handle messages here
70− return m, nil
277+// SelectNext selects the next message in the chat list.
278+func (m *Chat) SelectNext() {
279+ m.list.SelectNext()
71280}
72281
73−// View renders the chat model's view.
74−func (m *ChatModel) View() string {
75− return "Chat Model View"
282+// HandleMouseDown handles mouse down events for the chat component.
283+func (m *Chat) HandleMouseDown(x, y int) {
284+ m.list.HandleMouseDown(x, y)
76285}
77286
78−// ShortHelp returns a brief help view for the chat model.
79−func (m *ChatModel) ShortHelp() []key.Binding {
80− return nil
287+// HandleMouseUp handles mouse up events for the chat component.
288+func (m *Chat) HandleMouseUp(x, y int) {
289+ m.list.HandleMouseUp(x, y)
81290}
82291
83−// FullHelp returns a detailed help view for the chat model.
84−func (m *ChatModel) FullHelp() [][]key.Binding {
85− return nil
292+// HandleMouseDrag handles mouse drag events for the chat component.
293+func (m *Chat) HandleMouseDrag(x, y int) {
294+ m.list.HandleMouseDrag(x, y)
86295}
internal/ui/model/keys.go+29
@@ -17,6 +17,14 @@ type KeyMap struct {
1717 DeleteAllAttachments key.Binding
1818 }
1919
20+ Chat struct {
21+ NewSession key.Binding
22+ AddAttachment key.Binding
23+ Cancel key.Binding
24+ Tab key.Binding
25+ Details key.Binding
26+ }
27+
2028 Initialize struct {
2129 Yes,
2230 No,
@@ -106,6 +114,27 @@ func DefaultKeyMap() KeyMap {
106114 key.WithHelp("ctrl+r+r", "delete all attachments"),
107115 )
108116
117+ km.Chat.NewSession = key.NewBinding(
118+ key.WithKeys("ctrl+n"),
119+ key.WithHelp("ctrl+n", "new session"),
120+ )
121+ km.Chat.AddAttachment = key.NewBinding(
122+ key.WithKeys("ctrl+f"),
123+ key.WithHelp("ctrl+f", "add attachment"),
124+ )
125+ km.Chat.Cancel = key.NewBinding(
126+ key.WithKeys("esc", "alt+esc"),
127+ key.WithHelp("esc", "cancel"),
128+ )
129+ km.Chat.Tab = key.NewBinding(
130+ key.WithKeys("tab"),
131+ key.WithHelp("tab", "change focus"),
132+ )
133+ km.Chat.Details = key.NewBinding(
134+ key.WithKeys("ctrl+d"),
135+ key.WithHelp("ctrl+d", "toggle details"),
136+ )
137+
109138 km.Initialize.Yes = key.NewBinding(
110139 key.WithKeys("y", "Y"),
111140 key.WithHelp("y", "yes"),
internal/ui/model/ui.go+97−48
@@ -2,7 +2,6 @@ package model
22
33import (
44 "context"
5− "fmt"
65 "image"
76 "math/rand"
87 "os"
@@ -22,7 +21,6 @@ import (
2221 "github.com/charmbracelet/crush/internal/session"
2322 "github.com/charmbracelet/crush/internal/ui/common"
2423 "github.com/charmbracelet/crush/internal/ui/dialog"
25− "github.com/charmbracelet/crush/internal/ui/list"
2624 "github.com/charmbracelet/crush/internal/ui/logo"
2725 "github.com/charmbracelet/crush/internal/ui/styles"
2826 "github.com/charmbracelet/crush/internal/version"
@@ -77,7 +75,6 @@ type UI struct {
7775 keyMap KeyMap
7876 keyenh tea.KeyboardEnhancementsMsg
7977
80− chat *list.List
8178 dialog *dialog.Overlay
8279 help help.Model
8380
@@ -100,6 +97,9 @@ type UI struct {
10097 readyPlaceholder string
10198 workingPlaceholder string
10299
100+ // Chat components
101+ chat *Chat
102+
103103 // onboarding state
104104 onboarding struct {
105105 yesInitializeSelected bool
@@ -113,6 +113,9 @@ type UI struct {
113113
114114 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
115115 sidebarLogo string
116+
117+ // Canvas for rendering
118+ canvas *uv.ScreenBuffer
116119}
117120
118121// New creates a new instance of the [UI] model.
@@ -125,7 +128,11 @@ func New(com *common.Common) *UI {
125128 ta.SetVirtualCursor(false)
126129 ta.Focus()
127130
128− l := list.New()
131+ ch := NewChat(com)
132+
133+ // TODO: Switch to lipgloss.Canvas when available
134+ canvas := uv.NewScreenBuffer(0, 0)
135+ canvas.Method = ansi.GraphemeWidth
129136
130137 ui := &UI{
131138 com: com,
@@ -135,7 +142,8 @@ func New(com *common.Common) *UI {
135142 focus: uiFocusNone,
136143 state: uiConfigure,
137144 textarea: ta,
138− chat: l,
145+ chat: ch,
146+ canvas: &canvas,
139147 }
140148
141149 // set onboarding state defaults
@@ -167,6 +175,10 @@ func (m *UI) Init() tea.Cmd {
167175 if m.QueryVersion {
168176 cmds = append(cmds, tea.RequestTerminalVersion)
169177 }
178+ allSessions, _ := m.com.App.Sessions.List(context.Background())
179+ if len(allSessions) > 0 {
180+ cmds = append(cmds, m.loadSession(allSessions[0].ID))
181+ }
170182 return tea.Batch(cmds...)
171183}
172184
@@ -182,6 +194,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182194 case sessionLoadedMsg:
183195 m.state = uiChat
184196 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)
199+ for _, message := range msgs {
200+ m.chat.AppendMessage(message)
201+ }
202+ m.chat.ScrollToBottom()
185203 case sessionFilesLoadedMsg:
186204 m.sessionFiles = msg.files
187205 case pubsub.Event[history.File]:
@@ -200,13 +218,53 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200218 case tea.WindowSizeMsg:
201219 m.width, m.height = msg.Width, msg.Height
202220 m.updateLayoutAndSize()
203− m.chat.ScrollToBottom()
221+ m.canvas.Resize(msg.Width, msg.Height)
204222 case tea.KeyboardEnhancementsMsg:
205223 m.keyenh = msg
206224 if msg.SupportsKeyDisambiguation() {
207225 m.keyMap.Models.SetHelp("ctrl+m", "models")
208226 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
209227 }
228+ case tea.MouseClickMsg:
229+ switch m.state {
230+ case uiChat:
231+ m.chat.HandleMouseDown(msg.X, msg.Y)
232+ }
233+
234+ case tea.MouseMotionMsg:
235+ switch m.state {
236+ case uiChat:
237+ if msg.Y <= 0 {
238+ m.chat.ScrollBy(-1)
239+ } else if msg.Y >= m.chat.Height()-1 {
240+ m.chat.ScrollBy(1)
241+ }
242+ m.chat.HandleMouseDrag(msg.X, msg.Y)
243+ }
244+
245+ case tea.MouseReleaseMsg:
246+ switch m.state {
247+ case uiChat:
248+ m.chat.HandleMouseUp(msg.X, msg.Y)
249+ }
250+ case tea.MouseWheelMsg:
251+ switch m.state {
252+ case uiChat:
253+ switch msg.Button {
254+ case tea.MouseWheelUp:
255+ m.chat.ScrollBy(-5)
256+ if !m.chat.SelectedItemInView() {
257+ m.chat.SelectPrev()
258+ m.chat.ScrollToSelected()
259+ }
260+ case tea.MouseWheelDown:
261+ m.chat.ScrollBy(5)
262+ if !m.chat.SelectedItemInView() {
263+ m.chat.SelectNext()
264+ m.chat.ScrollToSelected()
265+ }
266+ }
267+ }
210268 case tea.KeyPressMsg:
211269 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
212270 }
@@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
243301 }
244302
245303 switch {
246− case msg.String() == "ctrl+shift+t":
247− m.chat.SelectPrev()
248− case msg.String() == "ctrl+t":
249− m.focus = uiFocusMain
250− m.state = uiChat
251− if m.chat.Len() > 0 {
252− m.chat.AppendItem(list.Gap)
253− }
254− m.chat.AppendItem(
255− list.NewStringItem(
256− fmt.Sprintf("%d", m.chat.Len()),
257− fmt.Sprintf("Welcome to Crush Chat! %d", rand.Intn(1000)),
258− ).WithFocusStyles(&m.com.Styles.BorderFocus, &m.com.Styles.BorderBlur),
259− )
260− m.chat.SetSelectedIndex(m.chat.Len() - 1)
261− m.chat.Focus()
262− m.chat.ScrollToBottom()
263304 case key.Matches(msg, m.keyMap.Tab):
264305 switch m.state {
265306 case uiChat:
266307 if m.focus == uiFocusMain {
267308 m.focus = uiFocusEditor
268309 cmds = append(cmds, m.textarea.Focus())
310+ m.chat.Blur()
269311 } else {
270312 m.focus = uiFocusMain
271313 m.textarea.Blur()
314+ m.chat.Focus()
315+ m.chat.SetSelectedIndex(m.chat.Len() - 1)
272316 }
273317 }
274318 case key.Matches(msg, m.keyMap.Help):
@@ -298,65 +342,72 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
298342
299343// Draw implements [tea.Layer] and draws the UI model.
300344func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
345+ layout := generateLayout(m, area.Dx(), area.Dy())
346+
347+ if m.layout != layout {
348+ m.layout = layout
349+ m.updateSize()
350+ }
351+
301352 // Clear the screen first
302353 screen.Clear(scr)
303354
304355 switch m.state {
305356 case uiConfigure:
306357 header := uv.NewStyledString(m.header)
307− header.Draw(scr, m.layout.header)
358+ header.Draw(scr, layout.header)
308359
309− mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
310− Height(m.layout.main.Dy()).
360+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
361+ Height(layout.main.Dy()).
311362 Background(lipgloss.ANSIColor(rand.Intn(256))).
312363 Render(" Configure ")
313364 main := uv.NewStyledString(mainView)
314− main.Draw(scr, m.layout.main)
365+ main.Draw(scr, layout.main)
315366
316367 case uiInitialize:
317368 header := uv.NewStyledString(m.header)
318− header.Draw(scr, m.layout.header)
369+ header.Draw(scr, layout.header)
319370
320371 main := uv.NewStyledString(m.initializeView())
321− main.Draw(scr, m.layout.main)
372+ main.Draw(scr, layout.main)
322373
323374 case uiLanding:
324375 header := uv.NewStyledString(m.header)
325− header.Draw(scr, m.layout.header)
376+ header.Draw(scr, layout.header)
326377 main := uv.NewStyledString(m.landingView())
327− main.Draw(scr, m.layout.main)
378+ main.Draw(scr, layout.main)
328379
329380 editor := uv.NewStyledString(m.textarea.View())
330− editor.Draw(scr, m.layout.editor)
381+ editor.Draw(scr, layout.editor)
331382
332383 case uiChat:
333384 header := uv.NewStyledString(m.header)
334− header.Draw(scr, m.layout.header)
335− m.drawSidebar(scr, m.layout.sidebar)
385+ header.Draw(scr, layout.header)
386+ m.drawSidebar(scr, layout.sidebar)
336387
337− m.chat.Draw(scr, m.layout.main)
388+ m.chat.Draw(scr, layout.main)
338389
339390 editor := uv.NewStyledString(m.textarea.View())
340− editor.Draw(scr, m.layout.editor)
391+ editor.Draw(scr, layout.editor)
341392
342393 case uiChatCompact:
343394 header := uv.NewStyledString(m.header)
344− header.Draw(scr, m.layout.header)
395+ header.Draw(scr, layout.header)
345396
346− mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
347− Height(m.layout.main.Dy()).
397+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
398+ Height(layout.main.Dy()).
348399 Background(lipgloss.ANSIColor(rand.Intn(256))).
349400 Render(" Compact Chat Messages ")
350401 main := uv.NewStyledString(mainView)
351− main.Draw(scr, m.layout.main)
402+ main.Draw(scr, layout.main)
352403
353404 editor := uv.NewStyledString(m.textarea.View())
354− editor.Draw(scr, m.layout.editor)
405+ editor.Draw(scr, layout.editor)
355406 }
356407
357408 // Add help layer
358409 help := uv.NewStyledString(m.help.View(m))
359− help.Draw(scr, m.layout.help)
410+ help.Draw(scr, layout.help)
360411
361412 // Debugging rendering (visually see when the tui rerenders)
362413 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
@@ -401,14 +452,11 @@ func (m *UI) View() tea.View {
401452 v.AltScreen = true
402453 v.BackgroundColor = m.com.Styles.Background
403454 v.Cursor = m.Cursor()
455+ v.MouseMode = tea.MouseModeCellMotion
404456
405− // TODO: Switch to lipgloss.Canvas when available
406− canvas := uv.NewScreenBuffer(m.width, m.height)
407− canvas.Method = ansi.GraphemeWidth
408−
409− m.Draw(canvas, canvas.Bounds())
457+ m.Draw(m.canvas, m.canvas.Bounds())
410458
411− content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
459+ content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines
412460 contentLines := strings.Split(content, "\n")
413461 for i, line := range contentLines {
414462 // Trim trailing spaces for concise rendering
@@ -680,6 +728,7 @@ func generateLayout(m *UI, w, h int) layout {
680728 // Add padding left
681729 sideRect.Min.X += 1
682730 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
731+ mainRect.Max.X -= 1 // Add padding right
683732 // Add bottom margin to main
684733 mainRect.Max.Y -= 1
685734 layout.sidebar = sideRect
internal/ui/styles/styles.go+41−1
@@ -151,6 +151,20 @@ type Styles struct {
151151 Additions lipgloss.Style
152152 Deletions lipgloss.Style
153153 }
154+
155+ // Chat
156+ Chat struct {
157+ UserMessageBlurred lipgloss.Style
158+ UserMessageFocused lipgloss.Style
159+ AssistantMessageBlurred lipgloss.Style
160+ AssistantMessageFocused lipgloss.Style
161+ NoContentMessage lipgloss.Style
162+ ThinkingMessage lipgloss.Style
163+
164+ ErrorTag lipgloss.Style
165+ ErrorTitle lipgloss.Style
166+ ErrorDetails lipgloss.Style
167+ }
154168}
155169
156170func DefaultStyles() Styles {
@@ -194,12 +208,14 @@ func DefaultStyles() Styles {
194208 greenDark = charmtone.Guac
195209 // greenLight = charmtone.Bok
196210
197− // red = charmtone.Coral
211+ red = charmtone.Coral
198212 redDark = charmtone.Sriracha
199213 // redLight = charmtone.Salmon
200214 // cherry = charmtone.Cherry
201215 )
202216
217+ normalBorder := lipgloss.NormalBorder()
218+
203219 base := lipgloss.NewStyle().Foreground(fgBase)
204220
205221 s := Styles{}
@@ -607,9 +623,33 @@ func DefaultStyles() Styles {
607623 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
608624 s.LSP.InfoDiagnostic = s.Base.Foreground(info)
609625
626+ // Files
610627 s.Files.Path = s.Muted
611628 s.Files.Additions = s.Base.Foreground(greenDark)
612629 s.Files.Deletions = s.Base.Foreground(redDark)
630+
631+ // Chat
632+ messageFocussedBorder := lipgloss.Border{
633+ Left: "▌",
634+ }
635+
636+ s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase)
637+ s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
638+ BorderForeground(primary).BorderStyle(normalBorder)
639+ s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
640+ BorderForeground(primary).BorderStyle(messageFocussedBorder)
641+ s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2)
642+ s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
643+ BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
644+ s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10)
645+ s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
646+ Background(red).Foreground(white)
647+ s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
648+ s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
649+
650+ // Text selection.
651+ s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
652+
613653 return s
614654}
615655