on main help sign in
refactor(chat): implement user message (#1644)
4f07a54Kujtim Hoxha
CRUSH.md → AGENTS.mdrenamed+3
@@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) {
70- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
70- ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc).
71- Try to keep commits to one line, not including your attribution. Only use
71- Try to keep commits to one line, not including your attribution. Only use
72 multi-line commits when additional context is truly necessary.
72 multi-line commits when additional context is truly necessary.
73+
74+## Working on the TUI (UI)
75+Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file
cspell.json+1−1
@@ -1 +1 @@
1−{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]}
1+{"version":"0.2","language":"en","flagWords":[],"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim"]}
internal/ui/AGENTS.mdnew file+17
@@ -0,0 +1,17 @@
1+# UI Development Instructions
2+
3+## General guideline
4+- Never use commands to send messages when you can directly mutate children or state
5+- Keep things simple do not overcomplicated
6+- Create files if needed to separate logic do not nest models
7+
8+## Big model
9+Keep most of the logic and state in the main model `internal/ui/model/ui.go`.
10+
11+
12+## When working on components
13+Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods.
14+
15+## When adding logic that has to do with the chat
16+Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component.
17+
internal/ui/chat/messages.go+139−1
@@ -1,9 +1,147 @@
1package chat
1package chat
2
2
3+import (
4+ "image"
5+
3−import "github.com/charmbracelet/crush/internal/message"
6+ "github.com/charmbracelet/crush/internal/message"
7+ "github.com/charmbracelet/crush/internal/ui/list"
8+ "github.com/charmbracelet/crush/internal/ui/styles"
9+)
10+
11+// this is the total width that is taken up by the border + padding
12+// we also cap the width so text is readable to the maxTextWidth(120)
13+const messageLeftPaddingTotal = 2
14+
15+// maxTextWidth is the maximum width text messages can be
16+const maxTextWidth = 120
17+
18+// Identifiable is an interface for items that can provide a unique identifier.
19+type Identifiable interface {
20+ ID() string
21+}
22+
23+// MessageItem represents a [message.Message] item that can be displayed in the
24+// UI and be part of a [list.List] identifiable by a unique ID.
25+type MessageItem interface {
26+ list.Item
27+ list.Highlightable
28+ list.Focusable
29+ Identifiable
30+}
4
31
5// SendMsg represents a message to send a chat message.
32// SendMsg represents a message to send a chat message.
6type SendMsg struct {
33type SendMsg struct {
7 Text string
34 Text string
8 Attachments []message.Attachment
35 Attachments []message.Attachment
9}
36}
37+
38+type highlightableMessageItem struct {
39+ startLine int
40+ startCol int
41+ endLine int
42+ endCol int
43+ highlighter list.Highlighter
44+}
45+
46+// isHighlighted returns true if the item has a highlight range set.
47+func (h *highlightableMessageItem) isHighlighted() bool {
48+ return h.startLine != -1 || h.endLine != -1
49+}
50+
51+// renderHighlighted highlights the content if necessary.
52+func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string {
53+ if !h.isHighlighted() {
54+ return content
55+ }
56+ area := image.Rect(0, 0, width, height)
57+ return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter)
58+}
59+
60+// Highlight implements MessageItem.
61+func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) {
62+ // Adjust columns for the style's left inset (border + padding) since we
63+ // highlight the content only.
64+ offset := messageLeftPaddingTotal
65+ h.startLine = startLine
66+ h.startCol = max(0, startCol-offset)
67+ h.endLine = endLine
68+ if endCol >= 0 {
69+ h.endCol = max(0, endCol-offset)
70+ } else {
71+ h.endCol = endCol
72+ }
73+}
74+
75+func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
76+ return &highlightableMessageItem{
77+ startLine: -1,
78+ startCol: -1,
79+ endLine: -1,
80+ endCol: -1,
81+ highlighter: list.ToHighlighter(sty.TextSelection),
82+ }
83+}
84+
85+// cachedMessageItem caches rendered message content to avoid re-rendering.
86+//
87+// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
88+//
89+// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
90+// the issue with that could be memory usage
91+type cachedMessageItem struct {
92+ // rendered is the cached rendered string
93+ rendered string
94+ // width and height are the dimensions of the cached render
95+ width int
96+ height int
97+}
98+
99+// getCachedRender returns the cached render if it exists for the given width.
100+func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) {
101+ if c.width == width && c.rendered != "" {
102+ return c.rendered, c.height, true
103+ }
104+ return "", 0, false
105+}
106+
107+// setCachedRender sets the cached render.
108+func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) {
109+ c.rendered = rendered
110+ c.width = width
111+ c.height = height
112+}
113+
114+// cappedMessageWidth returns the maximum width for message content for readability.
115+func cappedMessageWidth(availableWidth int) int {
116+ return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
117+}
118+
119+// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
120+// all parts of the message as [MessageItem]s.
121+//
122+// For assistant messages with tool calls, pass a toolResults map to link results.
123+// Use BuildToolResultMap to create this map from all messages in a session.
124+func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
125+ switch msg.Role {
126+ case message.User:
127+ return []MessageItem{NewUserMessageItem(sty, msg)}
128+ }
129+ return []MessageItem{}
130+}
131+
132+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
133+// Tool result messages (role == message.Tool) contain the results that should be linked
134+// to tool calls in assistant messages.
135+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
136+ resultMap := make(map[string]message.ToolResult)
137+ for _, msg := range messages {
138+ if msg.Role == message.Tool {
139+ for _, result := range msg.ToolResults() {
140+ if result.ToolCallID != "" {
141+ resultMap[result.ToolCallID] = result
142+ }
143+ }
144+ }
145+ }
146+ return resultMap
147+}
internal/ui/chat/user.gonew file+122
@@ -0,0 +1,122 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/ui/common"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+ "github.com/charmbracelet/x/ansi"
13+)
14+
15+// UserMessageItem represents a user message in the chat UI.
16+type UserMessageItem struct {
17+ *highlightableMessageItem
18+ *cachedMessageItem
19+ message *message.Message
20+ sty *styles.Styles
21+ focused bool
22+}
23+
24+// NewUserMessageItem creates a new UserMessageItem.
25+func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
26+ return &UserMessageItem{
27+ highlightableMessageItem: defaultHighlighter(sty),
28+ cachedMessageItem: &cachedMessageItem{},
29+ message: message,
30+ sty: sty,
31+ focused: false,
32+ }
33+}
34+
35+// Render implements MessageItem.
36+func (m *UserMessageItem) Render(width int) string {
37+ cappedWidth := cappedMessageWidth(width)
38+
39+ style := m.sty.Chat.Message.UserBlurred
40+ if m.focused {
41+ style = m.sty.Chat.Message.UserFocused
42+ }
43+
44+ content, height, ok := m.getCachedRender(cappedWidth)
45+ // cache hit
46+ if ok {
47+ return style.Render(m.renderHighlighted(content, cappedWidth, height))
48+ }
49+
50+ renderer := common.MarkdownRenderer(m.sty, cappedWidth)
51+
52+ msgContent := strings.TrimSpace(m.message.Content().Text)
53+ result, err := renderer.Render(msgContent)
54+ if err != nil {
55+ content = msgContent
56+ } else {
57+ content = strings.TrimSuffix(result, "\n")
58+ }
59+
60+ if len(m.message.BinaryContent()) > 0 {
61+ attachmentsStr := m.renderAttachments(cappedWidth)
62+ content = strings.Join([]string{content, "", attachmentsStr}, "\n")
63+ }
64+
65+ height = lipgloss.Height(content)
66+ m.setCachedRender(content, cappedWidth, height)
67+ return style.Render(m.renderHighlighted(content, cappedWidth, height))
68+}
69+
70+// SetFocused implements MessageItem.
71+func (m *UserMessageItem) SetFocused(focused bool) {
72+ m.focused = focused
73+}
74+
75+// ID implements MessageItem.
76+func (m *UserMessageItem) ID() string {
77+ return m.message.ID
78+}
79+
80+// renderAttachments renders attachments with wrapping if they exceed the width.
81+// TODO: change the styles here so they match the new design
82+func (m *UserMessageItem) renderAttachments(width int) string {
83+ const maxFilenameWidth = 10
84+
85+ attachments := make([]string, len(m.message.BinaryContent()))
86+ for i, attachment := range m.message.BinaryContent() {
87+ filename := filepath.Base(attachment.Path)
88+ attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
89+ " %s %s ",
90+ styles.DocumentIcon,
91+ ansi.Truncate(filename, maxFilenameWidth, "…"),
92+ ))
93+ }
94+
95+ // Wrap attachments into lines that fit within the width.
96+ var lines []string
97+ var currentLine []string
98+ currentWidth := 0
99+
100+ for _, att := range attachments {
101+ attWidth := lipgloss.Width(att)
102+ sepWidth := 1
103+ if len(currentLine) == 0 {
104+ sepWidth = 0
105+ }
106+
107+ if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 {
108+ lines = append(lines, strings.Join(currentLine, " "))
109+ currentLine = []string{att}
110+ currentWidth = attWidth
111+ } else {
112+ currentLine = append(currentLine, att)
113+ currentWidth += sepWidth + attWidth
114+ }
115+ }
116+
117+ if len(currentLine) > 0 {
118+ lines = append(lines, strings.Join(currentLine, " "))
119+ }
120+
121+ return strings.Join(lines, "\n")
122+}
internal/ui/dialog/sessions_item.go+2−2
@@ -37,7 +37,7 @@ var _ ListItem = &SessionItem{}
37
37
38// Filter returns the filterable value of the session.
38// Filter returns the filterable value of the session.
39func (s *SessionItem) Filter() string {
39func (s *SessionItem) Filter() string {
40− return s.Session.Title
40+ return s.Title
41}
41}
42
42
43// ID returns the unique identifier of the session.
43// ID returns the unique identifier of the session.
@@ -53,7 +53,7 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) {
53
53
54// Render returns the string representation of the session item.
54// Render returns the string representation of the session item.
55func (s *SessionItem) Render(width int) string {
55func (s *SessionItem) Render(width int) string {
56− return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m)
56+ return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m)
57}
57}
58
58
59func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
59func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string {
internal/ui/list/highlight.go+8
@@ -31,6 +31,14 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin
31 styled := uv.NewStyledString(content)
31 styled := uv.NewStyledString(content)
32 styled.Draw(&buf, area)
32 styled.Draw(&buf, area)
33
33
34+ // Treat -1 as "end of content"
35+ if endLine < 0 {
36+ endLine = height - 1
37+ }
38+ if endCol < 0 {
39+ endCol = width
40+ }
41+
34 for y := startLine; y <= endLine && y < height; y++ {
42 for y := startLine; y <= endLine && y < height; y++ {
35 if y >= buf.Height() {
43 if y >= buf.Height() {
36 break
44 break
internal/ui/model/chat.go+3−2
@@ -1,6 +1,7 @@
1package model
1package model
2
2
3import (
3import (
4+ "github.com/charmbracelet/crush/internal/ui/chat"
4 "github.com/charmbracelet/crush/internal/ui/common"
5 "github.com/charmbracelet/crush/internal/ui/common"
5 "github.com/charmbracelet/crush/internal/ui/list"
6 "github.com/charmbracelet/crush/internal/ui/list"
6 uv "github.com/charmbracelet/ultraviolet"
7 uv "github.com/charmbracelet/ultraviolet"
@@ -63,7 +64,7 @@ func (m *Chat) PrependItems(items ...list.Item) {
63}
64}
64
65
65// SetMessages sets the chat messages to the provided list of message items.
66// SetMessages sets the chat messages to the provided list of message items.
66−func (m *Chat) SetMessages(msgs ...MessageItem) {
67+func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
67 items := make([]list.Item, len(msgs))
68 items := make([]list.Item, len(msgs))
68 for i, msg := range msgs {
69 for i, msg := range msgs {
69 items[i] = msg
70 items[i] = msg
@@ -73,7 +74,7 @@ func (m *Chat) SetMessages(msgs ...MessageItem) {
73}
74}
74
75
75// AppendMessages appends a new message item to the chat list.
76// AppendMessages appends a new message item to the chat list.
76−func (m *Chat) AppendMessages(msgs ...MessageItem) {
77+func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
77 items := make([]list.Item, len(msgs))
78 items := make([]list.Item, len(msgs))
78 for i, msg := range msgs {
79 for i, msg := range msgs {
79 items[i] = msg
80 items[i] = msg
internal/ui/model/items.godeleted−548
@@ -1,548 +0,0 @@
1−package model
2−
3−import (
4− "fmt"
5− "path/filepath"
6− "strings"
7− "time"
8−
9− "charm.land/lipgloss/v2"
10− "github.com/charmbracelet/x/ansi"
11−
12− "github.com/charmbracelet/crush/internal/config"
13− "github.com/charmbracelet/crush/internal/message"
14− "github.com/charmbracelet/crush/internal/ui/common"
15− "github.com/charmbracelet/crush/internal/ui/list"
16− "github.com/charmbracelet/crush/internal/ui/styles"
17− "github.com/charmbracelet/crush/internal/ui/toolrender"
18−)
19−
20−// Identifiable is an interface for items that can provide a unique identifier.
21−type Identifiable interface {
22− ID() string
23−}
24−
25−// MessageItem represents a [message.Message] item that can be displayed in the
26−// UI and be part of a [list.List] identifiable by a unique ID.
27−type MessageItem interface {
28− list.Item
29− list.Item
30− Identifiable
31−}
32−
33−// MessageContentItem represents rendered message content (text, markdown, errors, etc).
34−type MessageContentItem struct {
35− id string
36− content string
37− role message.MessageRole
38− isMarkdown bool
39− maxWidth int
40− sty *styles.Styles
41−}
42−
43−// NewMessageContentItem creates a new message content item.
44−func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
45− m := &MessageContentItem{
46− id: id,
47− content: content,
48− isMarkdown: isMarkdown,
49− role: role,
50− maxWidth: 120,
51− sty: sty,
52− }
53− return m
54−}
55−
56−// ID implements Identifiable.
57−func (m *MessageContentItem) ID() string {
58− return m.id
59−}
60−
61−// FocusStyle returns the focus style.
62−func (m *MessageContentItem) FocusStyle() lipgloss.Style {
63− if m.role == message.User {
64− return m.sty.Chat.Message.UserFocused
65− }
66− return m.sty.Chat.Message.AssistantFocused
67−}
68−
69−// BlurStyle returns the blur style.
70−func (m *MessageContentItem) BlurStyle() lipgloss.Style {
71− if m.role == message.User {
72− return m.sty.Chat.Message.UserBlurred
73− }
74− return m.sty.Chat.Message.AssistantBlurred
75−}
76−
77−// HighlightStyle returns the highlight style.
78−func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
79− return m.sty.TextSelection
80−}
81−
82−// Render renders the content at the given width, using cache if available.
83−//
84−// It implements [list.Item].
85−func (m *MessageContentItem) Render(width int) string {
86− contentWidth := width
87− // Cap width to maxWidth for markdown
88− cappedWidth := contentWidth
89− if m.isMarkdown {
90− cappedWidth = min(contentWidth, m.maxWidth)
91− }
92−
93− var rendered string
94− if m.isMarkdown {
95− renderer := common.MarkdownRenderer(m.sty, cappedWidth)
96− result, err := renderer.Render(m.content)
97− if err != nil {
98− rendered = m.content
99− } else {
100− rendered = strings.TrimSuffix(result, "\n")
101− }
102− } else {
103− rendered = m.content
104− }
105−
106− return rendered
107−}
108−
109−// ToolCallItem represents a rendered tool call with its header and content.
110−type ToolCallItem struct {
111− id string
112− toolCall message.ToolCall
113− toolResult message.ToolResult
114− cancelled bool
115− isNested bool
116− maxWidth int
117− sty *styles.Styles
118−}
119−
120−// cachedToolRender stores both the rendered string and its height.
121−type cachedToolRender struct {
122− content string
123− height int
124−}
125−
126−// NewToolCallItem creates a new tool call item.
127−func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
128− t := &ToolCallItem{
129− id: id,
130− toolCall: toolCall,
131− toolResult: toolResult,
132− cancelled: cancelled,
133− isNested: isNested,
134− maxWidth: 120,
135− sty: sty,
136− }
137− return t
138−}
139−
140−// generateCacheKey creates a key that changes when tool call content changes.
141−func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
142− // Simple key based on result state - when result arrives or changes, key changes
143− return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
144−}
145−
146−// ID implements Identifiable.
147−func (t *ToolCallItem) ID() string {
148− return t.id
149−}
150−
151−// FocusStyle returns the focus style.
152−func (t *ToolCallItem) FocusStyle() lipgloss.Style {
153− return t.sty.Chat.Message.ToolCallFocused
154−}
155−
156−// BlurStyle returns the blur style.
157−func (t *ToolCallItem) BlurStyle() lipgloss.Style {
158− return t.sty.Chat.Message.ToolCallBlurred
159−}
160−
161−// HighlightStyle returns the highlight style.
162−func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
163− return t.sty.TextSelection
164−}
165−
166−// Render implements list.Item.
167−func (t *ToolCallItem) Render(width int) string {
168− // Render the tool call
169− ctx := &toolrender.RenderContext{
170− Call: t.toolCall,
171− Result: t.toolResult,
172− Cancelled: t.cancelled,
173− IsNested: t.isNested,
174− Width: width,
175− Styles: t.sty,
176− }
177−
178− rendered := toolrender.Render(ctx)
179− return rendered
180−}
181−
182−// AttachmentItem represents a file attachment in a user message.
183−type AttachmentItem struct {
184− id string
185− filename string
186− path string
187− sty *styles.Styles
188−}
189−
190−// NewAttachmentItem creates a new attachment item.
191−func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
192− a := &AttachmentItem{
193− id: id,
194− filename: filename,
195− path: path,
196− sty: sty,
197− }
198− return a
199−}
200−
201−// ID implements Identifiable.
202−func (a *AttachmentItem) ID() string {
203− return a.id
204−}
205−
206−// FocusStyle returns the focus style.
207−func (a *AttachmentItem) FocusStyle() lipgloss.Style {
208− return a.sty.Chat.Message.AssistantFocused
209−}
210−
211−// BlurStyle returns the blur style.
212−func (a *AttachmentItem) BlurStyle() lipgloss.Style {
213− return a.sty.Chat.Message.AssistantBlurred
214−}
215−
216−// HighlightStyle returns the highlight style.
217−func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
218− return a.sty.TextSelection
219−}
220−
221−// Render implements list.Item.
222−func (a *AttachmentItem) Render(width int) string {
223− const maxFilenameWidth = 10
224− content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
225− " %s %s ",
226− styles.DocumentIcon,
227− ansi.Truncate(a.filename, maxFilenameWidth, "..."),
228− ))
229−
230− return content
231−
232− // return a.RenderWithHighlight(content, width, a.CurrentStyle())
233−}
234−
235−// ThinkingItem represents thinking/reasoning content in assistant messages.
236−type ThinkingItem struct {
237− id string
238− thinking string
239− duration time.Duration
240− finished bool
241− maxWidth int
242− sty *styles.Styles
243−}
244−
245−// NewThinkingItem creates a new thinking item.
246−func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
247− t := &ThinkingItem{
248− id: id,
249− thinking: thinking,
250− duration: duration,
251− finished: finished,
252− maxWidth: 120,
253− sty: sty,
254− }
255− return t
256−}
257−
258−// ID implements Identifiable.
259−func (t *ThinkingItem) ID() string {
260− return t.id
261−}
262−
263−// FocusStyle returns the focus style.
264−func (t *ThinkingItem) FocusStyle() lipgloss.Style {
265− return t.sty.Chat.Message.AssistantFocused
266−}
267−
268−// BlurStyle returns the blur style.
269−func (t *ThinkingItem) BlurStyle() lipgloss.Style {
270− return t.sty.Chat.Message.AssistantBlurred
271−}
272−
273−// HighlightStyle returns the highlight style.
274−func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
275− return t.sty.TextSelection
276−}
277−
278−// Render implements list.Item.
279−func (t *ThinkingItem) Render(width int) string {
280− cappedWidth := min(width, t.maxWidth)
281−
282− renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
283− rendered, err := renderer.Render(t.thinking)
284− if err != nil {
285− // Fallback to line-by-line rendering
286− lines := strings.Split(t.thinking, "\n")
287− var content strings.Builder
288− lineStyle := t.sty.PanelMuted
289− for i, line := range lines {
290− if line == "" {
291− continue
292− }
293− content.WriteString(lineStyle.Width(cappedWidth).Render(line))
294− if i < len(lines)-1 {
295− content.WriteString("\n")
296− }
297− }
298− rendered = content.String()
299− }
300−
301− fullContent := strings.TrimSpace(rendered)
302−
303− // Add footer if finished
304− if t.finished && t.duration > 0 {
305− footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
306− fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
307− }
308−
309− result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
310−
311− return result
312−}
313−
314−// SectionHeaderItem represents a section header (e.g., assistant info).
315−type SectionHeaderItem struct {
316− id string
317− modelName string
318− duration time.Duration
319− isSectionHeader bool
320− sty *styles.Styles
321− content string
322−}
323−
324−// NewSectionHeaderItem creates a new section header item.
325−func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
326− s := &SectionHeaderItem{
327− id: id,
328− modelName: modelName,
329− duration: duration,
330− isSectionHeader: true,
331− sty: sty,
332− }
333− return s
334−}
335−
336−// ID implements Identifiable.
337−func (s *SectionHeaderItem) ID() string {
338− return s.id
339−}
340−
341−// IsSectionHeader returns true if this is a section header.
342−func (s *SectionHeaderItem) IsSectionHeader() bool {
343− return s.isSectionHeader
344−}
345−
346−// FocusStyle returns the focus style.
347−func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
348− return s.sty.Chat.Message.AssistantFocused
349−}
350−
351−// BlurStyle returns the blur style.
352−func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
353− return s.sty.Chat.Message.AssistantBlurred
354−}
355−
356−// Render implements list.Item.
357−func (s *SectionHeaderItem) Render(width int) string {
358− content := fmt.Sprintf("%s %s %s",
359− s.sty.Subtle.Render(styles.ModelIcon),
360− s.sty.Muted.Render(s.modelName),
361− s.sty.Subtle.Render(s.duration.String()),
362− )
363−
364− return s.sty.Chat.Message.SectionHeader.Render(content)
365−}
366−
367−// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
368−// all parts of the message as [MessageItem]s.
369−//
370−// For assistant messages with tool calls, pass a toolResults map to link results.
371−// Use BuildToolResultMap to create this map from all messages in a session.
372−func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
373− var items []MessageItem
374−
375− // Skip tool result messages - they're displayed inline with tool calls
376− if msg.Role == message.Tool {
377− return items
378− }
379−
380− // Process user messages
381− if msg.Role == message.User {
382− // Add main text content
383− content := msg.Content().String()
384− if content != "" {
385− item := NewMessageContentItem(
386− fmt.Sprintf("%s-content", msg.ID),
387− content,
388− msg.Role,
389− true, // User messages are markdown
390− sty,
391− )
392− items = append(items, item)
393− }
394−
395− // Add attachments
396− for i, attachment := range msg.BinaryContent() {
397− filename := filepath.Base(attachment.Path)
398− item := NewAttachmentItem(
399− fmt.Sprintf("%s-attachment-%d", msg.ID, i),
400− filename,
401− attachment.Path,
402− sty,
403− )
404− items = append(items, item)
405− }
406−
407− return items
408− }
409−
410− // Process assistant messages
411− if msg.Role == message.Assistant {
412− // Check if we need to add a section header
413− finishData := msg.FinishPart()
414− if finishData != nil && msg.Model != "" {
415− model := config.Get().GetModel(msg.Provider, msg.Model)
416− modelName := "Unknown Model"
417− if model != nil {
418− modelName = model.Name
419− }
420−
421− // Calculate duration (this would need the last user message time)
422− duration := time.Duration(0)
423− if finishData.Time > 0 {
424− duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
425− }
426−
427− header := NewSectionHeaderItem(
428− fmt.Sprintf("%s-header", msg.ID),
429− modelName,
430− duration,
431− sty,
432− )
433− items = append(items, header)
434− }
435−
436− // Add thinking content if present
437− reasoning := msg.ReasoningContent()
438− if strings.TrimSpace(reasoning.Thinking) != "" {
439− duration := time.Duration(0)
440− if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
441− duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
442− }
443−
444− item := NewThinkingItem(
445− fmt.Sprintf("%s-thinking", msg.ID),
446− reasoning.Thinking,
447− duration,
448− reasoning.FinishedAt > 0,
449− sty,
450− )
451− items = append(items, item)
452− }
453−
454− // Add main text content
455− content := msg.Content().String()
456− finished := msg.IsFinished()
457−
458− // Handle special finish states
459− if finished && content == "" && finishData != nil {
460− switch finishData.Reason {
461− case message.FinishReasonEndTurn:
462− // No content to show
463− case message.FinishReasonCanceled:
464− item := NewMessageContentItem(
465− fmt.Sprintf("%s-content", msg.ID),
466− "*Canceled*",
467− msg.Role,
468− true,
469− sty,
470− )
471− items = append(items, item)
472− case message.FinishReasonError:
473− // Render error
474− errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
475− truncated := ansi.Truncate(finishData.Message, 100, "...")
476− title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
477− details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
478− errorContent := fmt.Sprintf("%s\n\n%s", title, details)
479−
480− item := NewMessageContentItem(
481− fmt.Sprintf("%s-error", msg.ID),
482− errorContent,
483− msg.Role,
484− false,
485− sty,
486− )
487− items = append(items, item)
488− }
489− } else if content != "" {
490− item := NewMessageContentItem(
491− fmt.Sprintf("%s-content", msg.ID),
492− content,
493− msg.Role,
494− true, // Assistant messages are markdown
495− sty,
496− )
497− items = append(items, item)
498− }
499−
500− // Add tool calls
501− toolCalls := msg.ToolCalls()
502−
503− // Use passed-in tool results map (if nil, use empty map)
504− resultMap := toolResults
505− if resultMap == nil {
506− resultMap = make(map[string]message.ToolResult)
507− }
508−
509− for _, tc := range toolCalls {
510− result, hasResult := resultMap[tc.ID]
511− if !hasResult {
512− result = message.ToolResult{}
513− }
514−
515− item := NewToolCallItem(
516− fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
517− tc,
518− result,
519− false, // cancelled state would need to be tracked separately
520− false, // nested state would be detected from tool results
521− sty,
522− )
523−
524− items = append(items, item)
525− }
526−
527− return items
528− }
529−
530− return items
531−}
532−
533−// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
534−// Tool result messages (role == message.Tool) contain the results that should be linked
535−// to tool calls in assistant messages.
536−func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
537− resultMap := make(map[string]message.ToolResult)
538− for _, msg := range messages {
539− if msg.Role == message.Tool {
540− for _, result := range msg.ToolResults() {
541− if result.ToolCallID != "" {
542− resultMap[result.ToolCallID] = result
543− }
544− }
545− }
546− }
547− return resultMap
548−}
internal/ui/model/ui.go+10−17
@@ -26,9 +26,9 @@ import (
26 "github.com/charmbracelet/crush/internal/permission"
26 "github.com/charmbracelet/crush/internal/permission"
27 "github.com/charmbracelet/crush/internal/pubsub"
27 "github.com/charmbracelet/crush/internal/pubsub"
28 "github.com/charmbracelet/crush/internal/session"
28 "github.com/charmbracelet/crush/internal/session"
29− "github.com/charmbracelet/crush/internal/tui/components/chat"
30 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
31 "github.com/charmbracelet/crush/internal/tui/util"
30 "github.com/charmbracelet/crush/internal/tui/util"
31+ "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/common"
32 "github.com/charmbracelet/crush/internal/ui/common"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
34 "github.com/charmbracelet/crush/internal/ui/logo"
34 "github.com/charmbracelet/crush/internal/ui/logo"
@@ -203,13 +203,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203 cmds = append(cmds, uiutil.ReportError(err))
203 cmds = append(cmds, uiutil.ReportError(err))
204 break
204 break
205 }
205 }
206+ m.setSessionMessages(msgs)
206
207
207− if cmd := m.handleMessageEvents(msgs...); cmd != nil {
208− cmds = append(cmds, cmd)
209− }
210 case pubsub.Event[message.Message]:
208 case pubsub.Event[message.Message]:
211 // TODO: Finish implementing me
209 // TODO: Finish implementing me
212− cmds = append(cmds, m.handleMessageEvents(msg.Payload))
210+ // cmds = append(cmds, m.setMessageEvents(msg.Payload))
213 case pubsub.Event[history.File]:
211 case pubsub.Event[history.File]:
214 cmds = append(cmds, m.handleFileEvent(msg.Payload))
212 cmds = append(cmds, m.handleFileEvent(msg.Payload))
215 case pubsub.Event[app.LSPEvent]:
213 case pubsub.Event[app.LSPEvent]:
@@ -337,29 +335,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
337 return m, tea.Batch(cmds...)
335 return m, tea.Batch(cmds...)
338}
336}
339
337
338+// setSessionMessages sets the messages for the current session in the chat
340−func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd {
339+func (m *UI) setSessionMessages(msgs []message.Message) {
341 // Build tool result map to link tool calls with their results
340 // Build tool result map to link tool calls with their results
342 msgPtrs := make([]*message.Message, len(msgs))
341 msgPtrs := make([]*message.Message, len(msgs))
343 for i := range msgs {
342 for i := range msgs {
344 msgPtrs[i] = &msgs[i]
343 msgPtrs[i] = &msgs[i]
345 }
344 }
346− toolResultMap := BuildToolResultMap(msgPtrs)
345+ toolResultMap := chat.BuildToolResultMap(msgPtrs)
347
346
348 // Add messages to chat with linked tool results
347 // Add messages to chat with linked tool results
349− items := make([]MessageItem, 0, len(msgs)*2)
348+ items := make([]chat.MessageItem, 0, len(msgs)*2)
350 for _, msg := range msgPtrs {
349 for _, msg := range msgPtrs {
351− items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...)
350+ items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
352 }
351 }
353
352
354− if m.session == nil || m.session.ID == "" {
355− m.chat.SetMessages(items...)
353+ m.chat.SetMessages(items...)
356− } else {
357− m.chat.AppendMessages(items...)
358− }
359 m.chat.ScrollToBottom()
354 m.chat.ScrollToBottom()
360 m.chat.SelectLast()
355 m.chat.SelectLast()
361−
362− return nil
363}
356}
364
357
365func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
358func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -1179,7 +1172,7 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C
1179 return uiutil.ReportError(err)
1172 return uiutil.ReportError(err)
1180 }
1173 }
1181 session = newSession
1174 session = newSession
1182− cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
1175+ cmds = append(cmds, m.loadSession(session.ID))
1183 }
1176 }
1184 if m.com.App.AgentCoordinator == nil {
1177 if m.com.App.AgentCoordinator == nil {
1185 return util.ReportError(fmt.Errorf("coder agent is not initialized"))
1178 return util.ReportError(fmt.Errorf("coder agent is not initialized"))