on main help sign in
feat(ui): initial chat ui implementation
0633988Ayman Bagabas
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 (
5
5
6 uv "github.com/charmbracelet/ultraviolet"
6 uv "github.com/charmbracelet/ultraviolet"
7 "github.com/charmbracelet/ultraviolet/screen"
7 "github.com/charmbracelet/ultraviolet/screen"
8+ "github.com/charmbracelet/x/exp/ordered"
8)
9)
9
10
10// List is a scrollable list component that implements uv.Drawable.
11// List is a scrollable list component that implements uv.Drawable.
@@ -160,7 +161,7 @@ func (l *List) renderViewport() string {
160 for i := range lines {
161 for i := range lines {
161 lines[i] = emptyLine
162 lines[i] = emptyLine
162 }
163 }
163− return strings.Join(lines, "\r\n")
164+ return strings.Join(lines, "\n")
164 }
165 }
165 if srcEndY > len(buf.Lines) {
166 if srcEndY > len(buf.Lines) {
166 srcEndY = len(buf.Lines)
167 srcEndY = len(buf.Lines)
@@ -182,7 +183,7 @@ func (l *List) renderViewport() string {
182 lines[lineIdx] = emptyLine
183 lines[lineIdx] = emptyLine
183 }
184 }
184
185
185− return strings.Join(lines, "\r\n")
186+ return strings.Join(lines, "\n")
186}
187}
187
188
188// drawViewport draws the visible portion from master buffer to target screen.
189// 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) {
199 srcEndY := l.offset + area.Dy()
200 srcEndY := l.offset + area.Dy()
200
201
201 // Clamp to actual buffer bounds
202 // Clamp to actual buffer bounds
202− if srcStartY >= len(buf.Lines) {
203+ if srcStartY >= buf.Height() {
203 screen.ClearArea(scr, area)
204 screen.ClearArea(scr, area)
204 return
205 return
205 }
206 }
206− if srcEndY > len(buf.Lines) {
207+ if srcEndY > buf.Height() {
207− srcEndY = len(buf.Lines)
208+ srcEndY = buf.Height()
208 }
209 }
209
210
210 // Copy visible lines to target screen
211 // Copy visible lines to target screen
211 destY := area.Min.Y
212 destY := area.Min.Y
212 for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
213 for srcY := srcStartY; srcY < srcEndY && destY < area.Max.Y; srcY++ {
213− line := buf.Lines[srcY]
214+ line := buf.Line(srcY)
214 destX := area.Min.X
215 destX := area.Min.X
215
216
216 for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
217 for x := 0; x < len(line) && x < area.Dx() && destX < area.Max.X; x++ {
@@ -840,16 +841,7 @@ func (l *List) SelectedItemInView() bool {
840
841
841// clampOffset ensures offset is within valid bounds.
842// clampOffset ensures offset is within valid bounds.
842func (l *List) clampOffset() {
843func (l *List) clampOffset() {
843− maxOffset := l.totalHeight - l.height
844+ l.offset = ordered.Clamp(l.offset, 0, 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− }
853}
845}
854
846
855// focusSelectedItem focuses the currently selected item if it's focusable.
847// focusSelectedItem focuses the currently selected item if it's focusable.
internal/ui/model/chat.go+270−61
@@ -1,86 +1,295 @@
1package model
1package model
2
2
3import (
3import (
4− "charm.land/bubbles/v2/key"
4+ "fmt"
5+ "strings"
6+
5 tea "charm.land/bubbletea/v2"
7 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"
7 "github.com/charmbracelet/crush/internal/ui/common"
10 "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)
8)
91)
9
92
10−// ChatKeyMap defines key bindings for the chat model.
93+// NewChatMessageItem creates a new instance of [ChatMessageItem].
11−type ChatKeyMap struct {
94+func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem {
12− NewSession key.Binding
95+ c := new(ChatMessageItem)
13− AddAttachment key.Binding
96+
14− Cancel key.Binding
97+ switch msg.Role {
15− Tab key.Binding
98+ case message.User:
16− Details key.Binding
99+ item := list.NewMarkdownItem(msg.ID, msg.Content().String()).
17−}
100+ WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
18−
101+ item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
19−// DefaultChatKeyMap returns the default key bindings for the chat model.
102+ // TODO: Add attachments
20−func DefaultChatKeyMap() ChatKeyMap {
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+
21− return ChatKeyMap{
124+ return c
22− NewSession: key.NewBinding(
125+ }
23− key.WithKeys("ctrl+n"),
126+
24− key.WithHelp("ctrl+n", "new session"),
127+ if thinking || reasoningThinking != "" {
25− ),
128+ // TODO: animation item?
26− AddAttachment: key.NewBinding(
129+ // TODO: thinking item
27− key.WithKeys("ctrl+f"),
130+ thinkingContent = reasoningThinking
28− key.WithHelp("ctrl+f", "add attachment"),
131+ } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
29− ),
132+ content = "*Canceled*"
30− Cancel: key.NewBinding(
133+ }
31− key.WithKeys("esc", "alt+esc"),
134+
32− key.WithHelp("esc", "cancel"),
135+ var parts []string
33− ),
136+ if thinkingContent != "" {
34− Tab: key.NewBinding(
137+ parts = append(parts, thinkingContent)
35− key.WithKeys("tab"),
138+ }
36− key.WithHelp("tab", "change focus"),
139+
37− ),
140+ if content != "" {
38− Details: key.NewBinding(
141+ if len(parts) > 0 {
39− key.WithKeys("ctrl+d"),
142+ parts = append(parts, "")
40− key.WithHelp("ctrl+d", "toggle details"),
143+ }
41− ),
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
42 }
152 }
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()
43}
207}
44
208
45−// ChatModel represents the chat UI model.
209+// PrependItem prepends a new item to the chat list.
46−type ChatModel struct {
210+func (m *Chat) PrependItem(item list.Item) {
47− app *app.App
211+ m.list.PrependItem(item)
48− com *common.Common
212+}
49
213
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+ }
51}
221}
52
222
53−// NewChatModel creates a new instance of ChatModel.
223+// AppendItem appends a new item to the chat list.
54−func NewChatModel(com *common.Common, app *app.App) *ChatModel {
224+func (m *Chat) AppendItem(item list.Item) {
55− return &ChatModel{
225+ if m.Len() > 0 {
56− app: app,
226+ // Always add a spacer between messages
57− com: com,
227+ m.list.AppendItem(list.NewSpacerItem(uuid.NewString(), 1))
58− keyMap: DefaultChatKeyMap(),
59 }
228 }
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)
60}
270}
61
271
62−// Init initializes the chat model.
272+// SelectPrev selects the previous message in the chat list.
63−func (m *ChatModel) Init() tea.Cmd {
273+func (m *Chat) SelectPrev() {
64− return nil
274+ m.list.SelectPrev()
65}
275}
66
276
67−// Update handles incoming messages and updates the chat model state.
277+// SelectNext selects the next message in the chat list.
68−func (m *ChatModel) Update(msg tea.Msg) (*ChatModel, tea.Cmd) {
278+func (m *Chat) SelectNext() {
69− // Handle messages here
279+ m.list.SelectNext()
70− return m, nil
71}
280}
72
281
73−// View renders the chat model's view.
282+// HandleMouseDown handles mouse down events for the chat component.
74−func (m *ChatModel) View() string {
283+func (m *Chat) HandleMouseDown(x, y int) {
75− return "Chat Model View"
284+ m.list.HandleMouseDown(x, y)
76}
285}
77
286
78−// ShortHelp returns a brief help view for the chat model.
287+// HandleMouseUp handles mouse up events for the chat component.
79−func (m *ChatModel) ShortHelp() []key.Binding {
288+func (m *Chat) HandleMouseUp(x, y int) {
80− return nil
289+ m.list.HandleMouseUp(x, y)
81}
290}
82
291
83−// FullHelp returns a detailed help view for the chat model.
292+// HandleMouseDrag handles mouse drag events for the chat component.
84−func (m *ChatModel) FullHelp() [][]key.Binding {
293+func (m *Chat) HandleMouseDrag(x, y int) {
85− return nil
294+ m.list.HandleMouseDrag(x, y)
86}
295}
internal/ui/model/keys.go+29
@@ -17,6 +17,14 @@ type KeyMap struct {
17 DeleteAllAttachments key.Binding
17 DeleteAllAttachments key.Binding
18 }
18 }
19
19
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+
20 Initialize struct {
28 Initialize struct {
21 Yes,
29 Yes,
22 No,
30 No,
@@ -106,6 +114,27 @@ func DefaultKeyMap() KeyMap {
106 key.WithHelp("ctrl+r+r", "delete all attachments"),
114 key.WithHelp("ctrl+r+r", "delete all attachments"),
107 )
115 )
108
116
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+
109 km.Initialize.Yes = key.NewBinding(
138 km.Initialize.Yes = key.NewBinding(
110 key.WithKeys("y", "Y"),
139 key.WithKeys("y", "Y"),
111 key.WithHelp("y", "yes"),
140 key.WithHelp("y", "yes"),
internal/ui/model/ui.go+97−48
@@ -2,7 +2,6 @@ package model
2
2
3import (
3import (
4 "context"
4 "context"
5− "fmt"
6 "image"
5 "image"
7 "math/rand"
6 "math/rand"
8 "os"
7 "os"
@@ -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
79
77
80− chat *list.List
81 dialog *dialog.Overlay
78 dialog *dialog.Overlay
82 help help.Model
79 help help.Model
83
80
@@ -100,6 +97,9 @@ type UI struct {
100 readyPlaceholder string
97 readyPlaceholder string
101 workingPlaceholder string
98 workingPlaceholder string
102
99
100+ // Chat components
101+ chat *Chat
102+
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 {
113
113
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
116+
117+ // Canvas for rendering
118+ canvas *uv.ScreenBuffer
116}
119}
117
120
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()
127
130
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
129
136
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,
146+ canvas: &canvas,
139 }
147 }
140
148
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)
169 }
177 }
178+ allSessions, _ := m.com.App.Sessions.List(context.Background())
179+ if len(allSessions) > 0 {
180+ cmds = append(cmds, m.loadSession(allSessions[0].ID))
181+ }
170 return tea.Batch(cmds...)
182 return tea.Batch(cmds...)
171}
183}
172
184
@@ -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)
201+ }
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")
209 }
227 }
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+ }
210 case tea.KeyPressMsg:
268 case tea.KeyPressMsg:
211 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
269 cmds = append(cmds, m.handleKeyPressMsg(msg)...)
212 }
270 }
@@ -243,32 +301,18 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
243 }
301 }
244
302
245 switch {
303 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()
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())
310+ m.chat.Blur()
269 } else {
311 } else {
270 m.focus = uiFocusMain
312 m.focus = uiFocusMain
271 m.textarea.Blur()
313 m.textarea.Blur()
314+ m.chat.Focus()
315+ m.chat.SetSelectedIndex(m.chat.Len() - 1)
272 }
316 }
273 }
317 }
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) {
298
342
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())
346+
347+ if m.layout != layout {
348+ m.layout = layout
349+ m.updateSize()
350+ }
351+
301 // Clear the screen first
352 // Clear the screen first
302 screen.Clear(scr)
353 screen.Clear(scr)
303
354
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)
308
359
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)
315
366
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)
319
370
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)
322
373
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)
328
379
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)
331
382
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)
336
387
337− m.chat.Draw(scr, m.layout.main)
388+ m.chat.Draw(scr, layout.main)
338
389
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)
341
392
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)
345
396
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)
352
403
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)
355 }
406 }
356
407
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)
360
411
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
404
456
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())
410
458
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
internal/ui/styles/styles.go+41−1
@@ -151,6 +151,20 @@ type Styles struct {
151 Additions lipgloss.Style
151 Additions lipgloss.Style
152 Deletions lipgloss.Style
152 Deletions lipgloss.Style
153 }
153 }
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+ }
154}
168}
155
169
156func DefaultStyles() Styles {
170func DefaultStyles() Styles {
@@ -194,12 +208,14 @@ func DefaultStyles() Styles {
194 greenDark = charmtone.Guac
208 greenDark = charmtone.Guac
195 // greenLight = charmtone.Bok
209 // greenLight = charmtone.Bok
196
210
197− // red = charmtone.Coral
211+ red = charmtone.Coral
198 redDark = charmtone.Sriracha
212 redDark = charmtone.Sriracha
199 // redLight = charmtone.Salmon
213 // redLight = charmtone.Salmon
200 // cherry = charmtone.Cherry
214 // cherry = charmtone.Cherry
201 )
215 )
202
216
217+ normalBorder := lipgloss.NormalBorder()
218+
203 base := lipgloss.NewStyle().Foreground(fgBase)
219 base := lipgloss.NewStyle().Foreground(fgBase)
204
220
205 s := Styles{}
221 s := Styles{}
@@ -607,9 +623,33 @@ func DefaultStyles() Styles {
607 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
623 s.LSP.HintDiagnostic = s.Base.Foreground(fgHalfMuted)
608 s.LSP.InfoDiagnostic = s.Base.Foreground(info)
624 s.LSP.InfoDiagnostic = s.Base.Foreground(info)
609
625
626+ // Files
610 s.Files.Path = s.Muted
627 s.Files.Path = s.Muted
611 s.Files.Additions = s.Base.Foreground(greenDark)
628 s.Files.Additions = s.Base.Foreground(greenDark)
612 s.Files.Deletions = s.Base.Foreground(redDark)
629 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+
613 return s
653 return s
614}
654}
615
655