@@ -2,7 +2,6 @@ package model
7 "math/rand"
6 "math/rand"
@@ -22,7 +21,6 @@ import (
22 "github.com/charmbracelet/crush/internal/session"
21 "github.com/charmbracelet/crush/internal/session"
23 "github.com/charmbracelet/crush/internal/ui/common"
22 "github.com/charmbracelet/crush/internal/ui/common"
24 "github.com/charmbracelet/crush/internal/ui/dialog"
23 "github.com/charmbracelet/crush/internal/ui/dialog"
25− "github.com/charmbracelet/crush/internal/ui/list"
26 "github.com/charmbracelet/crush/internal/ui/logo"
24 "github.com/charmbracelet/crush/internal/ui/logo"
27 "github.com/charmbracelet/crush/internal/ui/styles"
25 "github.com/charmbracelet/crush/internal/ui/styles"
28 "github.com/charmbracelet/crush/internal/version"
26 "github.com/charmbracelet/crush/internal/version"
@@ -77,7 +75,6 @@ type UI struct {
77 keyMap KeyMap
75 keyMap KeyMap
78 keyenh tea.KeyboardEnhancementsMsg
76 keyenh tea.KeyboardEnhancementsMsg
81 dialog *dialog.Overlay
78 dialog *dialog.Overlay
82 help help.Model
79 help help.Model
@@ -100,6 +97,9 @@ type UI struct {
100 readyPlaceholder string
97 readyPlaceholder string
101 workingPlaceholder string
98 workingPlaceholder string
103 // onboarding state
103 // onboarding state
104 onboarding struct {
104 onboarding struct {
105 yesInitializeSelected bool
105 yesInitializeSelected bool
@@ -113,6 +113,9 @@ type UI struct {
114 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
114 // sidebarLogo keeps a cached version of the sidebar sidebarLogo.
115 sidebarLogo string
115 sidebarLogo string
117+ // Canvas for rendering
118+ canvas *uv.ScreenBuffer
118// New creates a new instance of the [UI] model.
121// New creates a new instance of the [UI] model.
@@ -125,7 +128,11 @@ func New(com *common.Common) *UI {
125 ta.SetVirtualCursor(false)
128 ta.SetVirtualCursor(false)
126 ta.Focus()
129 ta.Focus()
128− l := list.New()
131+ ch := NewChat(com)
133+ // TODO: Switch to lipgloss.Canvas when available
134+ canvas := uv.NewScreenBuffer(0, 0)
135+ canvas.Method = ansi.GraphemeWidth
130 ui := &UI{
137 ui := &UI{
131 com: com,
138 com: com,
@@ -135,7 +142,8 @@ func New(com *common.Common) *UI {
135 focus: uiFocusNone,
142 focus: uiFocusNone,
136 state: uiConfigure,
143 state: uiConfigure,
137 textarea: ta,
144 textarea: ta,
138− chat: l,
145+ chat: ch,
141 // set onboarding state defaults
149 // set onboarding state defaults
@@ -167,6 +175,10 @@ func (m *UI) Init() tea.Cmd {
167 if m.QueryVersion {
175 if m.QueryVersion {
168 cmds = append(cmds, tea.RequestTerminalVersion)
176 cmds = append(cmds, tea.RequestTerminalVersion)
178+ allSessions, _ := m.com.App.Sessions.List(context.Background())
179+ if len(allSessions) > 0 {
180+ cmds = append(cmds, m.loadSession(allSessions[0].ID))
170 return tea.Batch(cmds...)
182 return tea.Batch(cmds...)
@@ -182,6 +194,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
182 case sessionLoadedMsg:
194 case sessionLoadedMsg:
183 m.state = uiChat
195 m.state = uiChat
184 m.session = &msg.sess
196 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)
202+ m.chat.ScrollToBottom()
185 case sessionFilesLoadedMsg:
203 case sessionFilesLoadedMsg:
186 m.sessionFiles = msg.files
204 m.sessionFiles = msg.files
187 case pubsub.Event[history.File]:
205 case pubsub.Event[history.File]:
@@ -200,13 +218,53 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
200 case tea.WindowSizeMsg:
218 case tea.WindowSizeMsg:
201 m.width, m.height = msg.Width, msg.Height
219 m.width, m.height = msg.Width, msg.Height
202 m.updateLayoutAndSize()
220 m.updateLayoutAndSize()
203− m.chat.ScrollToBottom()
221+ m.canvas.Resize(msg.Width, msg.Height)
204 case tea.KeyboardEnhancementsMsg:
222 case tea.KeyboardEnhancementsMsg:
205 m.keyenh = msg
223 m.keyenh = msg
206 if msg.SupportsKeyDisambiguation() {
224 if msg.SupportsKeyDisambiguation() {
207 m.keyMap.Models.SetHelp("ctrl+m", "models")
225 m.keyMap.Models.SetHelp("ctrl+m", "models")
208 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
226 m.keyMap.Editor.Newline.SetHelp("shift+enter", "newline")
228+ case tea.MouseClickMsg:
231+ m.chat.HandleMouseDown(msg.X, msg.Y)
234+ case tea.MouseMotionMsg:
239+ } else if msg.Y >= m.chat.Height()-1 {
242+ m.chat.HandleMouseDrag(msg.X, msg.Y)
245+ case tea.MouseReleaseMsg:
248+ m.chat.HandleMouseUp(msg.X, msg.Y)
250+ case tea.MouseWheelMsg:
254+ case tea.MouseWheelUp:
256+ if !m.chat.SelectedItemInView() {
258+ m.chat.ScrollToSelected()
260+ case tea.MouseWheelDown:
262+ if !m.chat.SelectedItemInView() {
264+ m.chat.ScrollToSelected()
210 case tea.KeyPressMsg:
268 case tea.KeyPressMsg:
211 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
269 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
@@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
246− case msg.String() == "ctrl+shift+t":
248− case msg.String() == "ctrl+t":
249− m.focus = uiFocusMain
251− if m.chat.Len() > 0 {
252− m.chat.AppendItem(list.Gap)
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),
260− m.chat.SetSelectedIndex(m.chat.Len() - 1)
262− m.chat.ScrollToBottom()
263 case key.Matches(msg, m.keyMap.Tab):
304 case key.Matches(msg, m.keyMap.Tab):
264 switch m.state {
305 switch m.state {
265 case uiChat:
306 case uiChat:
266 if m.focus == uiFocusMain {
307 if m.focus == uiFocusMain {
267 m.focus = uiFocusEditor
308 m.focus = uiFocusEditor
268 cmds = append(cmds, m.textarea.Focus())
309 cmds = append(cmds, m.textarea.Focus())
270 m.focus = uiFocusMain
312 m.focus = uiFocusMain
271 m.textarea.Blur()
313 m.textarea.Blur()
315+ m.chat.SetSelectedIndex(m.chat.Len() - 1)
274 case key.Matches(msg, m.keyMap.Help):
318 case key.Matches(msg, m.keyMap.Help):
@@ -298,65 +342,72 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
299// Draw implements [tea.Layer] and draws the UI model.
343// Draw implements [tea.Layer] and draws the UI model.
300func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
344func (m *UI) Draw(scr uv.Screen, area uv.Rectangle) {
345+ layout := generateLayout(m, area.Dx(), area.Dy())
347+ if m.layout != layout {
301 // Clear the screen first
352 // Clear the screen first
302 screen.Clear(scr)
353 screen.Clear(scr)
304 switch m.state {
355 switch m.state {
305 case uiConfigure:
356 case uiConfigure:
306 header := uv.NewStyledString(m.header)
357 header := uv.NewStyledString(m.header)
307− header.Draw(scr, m.layout.header)
358+ header.Draw(scr, layout.header)
309− mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
360+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
310− Height(m.layout.main.Dy()).
361+ Height(layout.main.Dy()).
311 Background(lipgloss.ANSIColor(rand.Intn(256))).
362 Background(lipgloss.ANSIColor(rand.Intn(256))).
312 Render(" Configure ")
363 Render(" Configure ")
313 main := uv.NewStyledString(mainView)
364 main := uv.NewStyledString(mainView)
314− main.Draw(scr, m.layout.main)
365+ main.Draw(scr, layout.main)
316 case uiInitialize:
367 case uiInitialize:
317 header := uv.NewStyledString(m.header)
368 header := uv.NewStyledString(m.header)
318− header.Draw(scr, m.layout.header)
369+ header.Draw(scr, layout.header)
320 main := uv.NewStyledString(m.initializeView())
371 main := uv.NewStyledString(m.initializeView())
321− main.Draw(scr, m.layout.main)
372+ main.Draw(scr, layout.main)
323 case uiLanding:
374 case uiLanding:
324 header := uv.NewStyledString(m.header)
375 header := uv.NewStyledString(m.header)
325− header.Draw(scr, m.layout.header)
376+ header.Draw(scr, layout.header)
326 main := uv.NewStyledString(m.landingView())
377 main := uv.NewStyledString(m.landingView())
327− main.Draw(scr, m.layout.main)
378+ main.Draw(scr, layout.main)
329 editor := uv.NewStyledString(m.textarea.View())
380 editor := uv.NewStyledString(m.textarea.View())
330− editor.Draw(scr, m.layout.editor)
381+ editor.Draw(scr, layout.editor)
332 case uiChat:
383 case uiChat:
333 header := uv.NewStyledString(m.header)
384 header := uv.NewStyledString(m.header)
334− header.Draw(scr, m.layout.header)
385+ header.Draw(scr, layout.header)
335− m.drawSidebar(scr, m.layout.sidebar)
386+ m.drawSidebar(scr, layout.sidebar)
337− m.chat.Draw(scr, m.layout.main)
388+ m.chat.Draw(scr, layout.main)
339 editor := uv.NewStyledString(m.textarea.View())
390 editor := uv.NewStyledString(m.textarea.View())
340− editor.Draw(scr, m.layout.editor)
391+ editor.Draw(scr, layout.editor)
342 case uiChatCompact:
393 case uiChatCompact:
343 header := uv.NewStyledString(m.header)
394 header := uv.NewStyledString(m.header)
344− header.Draw(scr, m.layout.header)
395+ header.Draw(scr, layout.header)
346− mainView := lipgloss.NewStyle().Width(m.layout.main.Dx()).
397+ mainView := lipgloss.NewStyle().Width(layout.main.Dx()).
347− Height(m.layout.main.Dy()).
398+ Height(layout.main.Dy()).
348 Background(lipgloss.ANSIColor(rand.Intn(256))).
399 Background(lipgloss.ANSIColor(rand.Intn(256))).
349 Render(" Compact Chat Messages ")
400 Render(" Compact Chat Messages ")
350 main := uv.NewStyledString(mainView)
401 main := uv.NewStyledString(mainView)
351− main.Draw(scr, m.layout.main)
402+ main.Draw(scr, layout.main)
353 editor := uv.NewStyledString(m.textarea.View())
404 editor := uv.NewStyledString(m.textarea.View())
354− editor.Draw(scr, m.layout.editor)
405+ editor.Draw(scr, layout.editor)
357 // Add help layer
408 // Add help layer
358 help := uv.NewStyledString(m.help.View(m))
409 help := uv.NewStyledString(m.help.View(m))
359− help.Draw(scr, m.layout.help)
410+ help.Draw(scr, layout.help)
361 // Debugging rendering (visually see when the tui rerenders)
412 // Debugging rendering (visually see when the tui rerenders)
362 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
413 if os.Getenv("CRUSH_UI_DEBUG") == "true" {
@@ -401,14 +452,11 @@ func (m *UI) View() tea.View {
401 v.AltScreen = true
452 v.AltScreen = true
402 v.BackgroundColor = m.com.Styles.Background
453 v.BackgroundColor = m.com.Styles.Background
403 v.Cursor = m.Cursor()
454 v.Cursor = m.Cursor()
455+ v.MouseMode = tea.MouseModeCellMotion
405− // TODO: Switch to lipgloss.Canvas when available
406− canvas := uv.NewScreenBuffer(m.width, m.height)
407− canvas.Method = ansi.GraphemeWidth
409− m.Draw(canvas, canvas.Bounds())
457+ m.Draw(m.canvas, m.canvas.Bounds())
411− content := strings.ReplaceAll(canvas.Render(), "\r\n", "\n") // normalize newlines
459+ content := strings.ReplaceAll(m.canvas.Render(), "\r\n", "\n") // normalize newlines
412 contentLines := strings.Split(content, "\n")
460 contentLines := strings.Split(content, "\n")
413 for i, line := range contentLines {
461 for i, line := range contentLines {
414 // Trim trailing spaces for concise rendering
462 // Trim trailing spaces for concise rendering
@@ -680,6 +728,7 @@ func generateLayout(m *UI, w, h int) layout {
680 // Add padding left
728 // Add padding left
681 sideRect.Min.X += 1
729 sideRect.Min.X += 1
682 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
730 mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight))
731+ mainRect.Max.X -= 1 // Add padding right
683 // Add bottom margin to main
732 // Add bottom margin to main
684 mainRect.Max.Y -= 1
733 mainRect.Max.Y -= 1
685 layout.sidebar = sideRect
734 layout.sidebar = sideRect