@@ -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