on main help sign in
feat(ui): add common utils and refactor chat message items and tools
commit82bc601d267ea1e9b8b1fb2d966a2a965aa52782 authorAyman Bagabas <ayman.bagabas@gmail.com> dateFri 5 Dec 2025, 15:38 (1 year ago) parenta1648a8
7 files changed+2008−34
internal/ui/common/diff.go+16 internal/ui/common/highlight.go+57 internal/ui/model/chat.go+11−13 internal/ui/model/items.go+753 internal/ui/model/ui.go+15−2 internal/ui/styles/styles.go+267−19 internal/ui/toolrender/render.go+889
internal/ui/common/diff.gonew file+16
@@ -0,0 +1,16 @@
1+package common
2+
3+import (
4+ "github.com/alecthomas/chroma/v2"
5+ "github.com/charmbracelet/crush/internal/tui/exp/diffview"
6+ "github.com/charmbracelet/crush/internal/ui/styles"
7+)
8+
9+// DiffFormatter returns a diff formatter with the given styles that can be
10+// used to format diff outputs.
11+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
12+ formatDiff := diffview.New()
13+ style := chroma.MustNewStyle("crush", s.ChromaTheme())
14+ diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
15+ return diff
16+}
internal/ui/common/highlight.gonew file+57
@@ -0,0 +1,57 @@
1+package common
2+
3+import (
4+ "bytes"
5+ "image/color"
6+
7+ "github.com/alecthomas/chroma/v2"
8+ "github.com/alecthomas/chroma/v2/formatters"
9+ "github.com/alecthomas/chroma/v2/lexers"
10+ chromastyles "github.com/alecthomas/chroma/v2/styles"
11+ "github.com/charmbracelet/crush/internal/ui/styles"
12+)
13+
14+// SyntaxHighlight applies syntax highlighting to the given source code based
15+// on the file name and background color. It returns the highlighted code as a
16+// string.
17+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
18+ // Determine the language lexer to use
19+ l := lexers.Match(fileName)
20+ if l == nil {
21+ l = lexers.Analyse(source)
22+ }
23+ if l == nil {
24+ l = lexers.Fallback
25+ }
26+ l = chroma.Coalesce(l)
27+
28+ // Get the formatter
29+ f := formatters.Get("terminal16m")
30+ if f == nil {
31+ f = formatters.Fallback
32+ }
33+
34+ style := chroma.MustNewStyle("crush", st.ChromaTheme())
35+
36+ // Modify the style to use the provided background
37+ s, err := style.Builder().Transform(
38+ func(t chroma.StyleEntry) chroma.StyleEntry {
39+ r, g, b, _ := bg.RGBA()
40+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
41+ return t
42+ },
43+ ).Build()
44+ if err != nil {
45+ s = chromastyles.Fallback
46+ }
47+
48+ // Tokenize and format
49+ it, err := l.Tokenise(nil, source)
50+ if err != nil {
51+ return "", err
52+ }
53+
54+ var buf bytes.Buffer
55+ err = f.Format(&buf, s, it)
56+ return buf.String(), err
57+}
internal/ui/model/chat.go+11−13
@@ -65,7 +65,7 @@ type ChatNoContentItem struct {
6565func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
6666 c := new(ChatNoContentItem)
6767 c.StringItem = list.NewStringItem("No message content").
68− WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
68+ WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent)
6969 return c
7070}
7171
@@ -88,7 +88,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
8888 switch msg.Role {
8989 case message.User:
9090 item := list.NewMarkdownItem(msg.Content().String()).
91− WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
91+ WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred)
9292 item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
9393 // TODO: Add attachments
9494 c.item = item
@@ -102,13 +102,13 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
102102 reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
103103
104104 if finished && content == "" && finishedData.Reason == message.FinishReasonError {
105− tag := t.Chat.ErrorTag.Render("ERROR")
106− title := t.Chat.ErrorTitle.Render(finishedData.Message)
107− details := t.Chat.ErrorDetails.Render(finishedData.Details)
105+ tag := t.Chat.Message.ErrorTag.Render("ERROR")
106+ title := t.Chat.Message.ErrorTitle.Render(finishedData.Message)
107+ details := t.Chat.Message.ErrorDetails.Render(finishedData.Details)
108108 errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
109109
110110 item := list.NewStringItem(errContent).
111− WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
111+ WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
112112
113113 c.item = item
114114
@@ -136,7 +136,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
136136 }
137137
138138 item := list.NewMarkdownItem(strings.Join(parts, "\n")).
139− WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
139+ WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
140140 item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
141141
142142 c.item = item
@@ -234,12 +234,10 @@ func (m *Chat) PrependItem(item list.Item) {
234234 m.list.PrependItem(item)
235235}
236236
237−// AppendMessage appends a new message item to the chat list.
238−func (m *Chat) AppendMessage(msg message.Message) {
239− if msg.ID == "" {
240− m.AppendItem(NewChatNoContentItem(m.com.Styles))
241− } else {
242− m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
237+// AppendMessages appends a new message item to the chat list.
238+func (m *Chat) AppendMessages(msgs ...MessageItem) {
239+ for _, msg := range msgs {
240+ m.AppendItem(msg)
243241 }
244242}
245243
internal/ui/model/items.gonew file+753
@@ -0,0 +1,753 @@
1+package model
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+ "time"
8+
9+ "charm.land/lipgloss/v2"
10+ uv "github.com/charmbracelet/ultraviolet"
11+ "github.com/charmbracelet/x/ansi"
12+
13+ "github.com/charmbracelet/crush/internal/config"
14+ "github.com/charmbracelet/crush/internal/message"
15+ "github.com/charmbracelet/crush/internal/ui/common"
16+ "github.com/charmbracelet/crush/internal/ui/list"
17+ "github.com/charmbracelet/crush/internal/ui/styles"
18+ "github.com/charmbracelet/crush/internal/ui/toolrender"
19+)
20+
21+// Identifiable is an interface for items that can provide a unique identifier.
22+type Identifiable interface {
23+ ID() string
24+}
25+
26+// MessageItem represents a [message.Message] item that can be displayed in the
27+// UI and be part of a [list.List] identifiable by a unique ID.
28+type MessageItem interface {
29+ list.Item
30+ list.Focusable
31+ list.Highlightable
32+ Identifiable
33+}
34+
35+// MessageContentItem represents rendered message content (text, markdown, errors, etc).
36+type MessageContentItem struct {
37+ list.BaseFocusable
38+ list.BaseHighlightable
39+ id string
40+ content string
41+ isMarkdown bool
42+ maxWidth int
43+ cache map[int]string // Cache for rendered content at different widths
44+ sty *styles.Styles
45+}
46+
47+// NewMessageContentItem creates a new message content item.
48+func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
49+ m := &MessageContentItem{
50+ id: id,
51+ content: content,
52+ isMarkdown: isMarkdown,
53+ maxWidth: 120,
54+ cache: make(map[int]string),
55+ sty: sty,
56+ }
57+ m.InitHighlight()
58+ return m
59+}
60+
61+// ID implements Identifiable.
62+func (m *MessageContentItem) ID() string {
63+ return m.id
64+}
65+
66+// Height implements list.Item.
67+func (m *MessageContentItem) Height(width int) int {
68+ // Calculate content width accounting for frame size
69+ contentWidth := width
70+ if style := m.CurrentStyle(); style != nil {
71+ contentWidth -= style.GetHorizontalFrameSize()
72+ }
73+
74+ rendered := m.render(contentWidth)
75+
76+ // Apply focus/blur styling if configured to get accurate height
77+ if style := m.CurrentStyle(); style != nil {
78+ rendered = style.Render(rendered)
79+ }
80+
81+ return strings.Count(rendered, "\n") + 1
82+}
83+
84+// Draw implements list.Item.
85+func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
86+ width := area.Dx()
87+ height := area.Dy()
88+
89+ // Calculate content width accounting for frame size
90+ contentWidth := width
91+ style := m.CurrentStyle()
92+ if style != nil {
93+ contentWidth -= style.GetHorizontalFrameSize()
94+ }
95+
96+ rendered := m.render(contentWidth)
97+
98+ // Apply focus/blur styling if configured
99+ if style != nil {
100+ rendered = style.Render(rendered)
101+ }
102+
103+ // Create temp buffer to draw content with highlighting
104+ tempBuf := uv.NewScreenBuffer(width, height)
105+
106+ // Draw the rendered content to temp buffer
107+ styled := uv.NewStyledString(rendered)
108+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
109+
110+ // Apply highlighting if active
111+ m.ApplyHighlight(&tempBuf, width, height, style)
112+
113+ // Copy temp buffer to actual screen at the target area
114+ tempBuf.Draw(scr, area)
115+}
116+
117+// render renders the content at the given width, using cache if available.
118+func (m *MessageContentItem) render(width int) string {
119+ // Cap width to maxWidth for markdown
120+ cappedWidth := width
121+ if m.isMarkdown {
122+ cappedWidth = min(width, m.maxWidth)
123+ }
124+
125+ // Check cache first
126+ if cached, ok := m.cache[cappedWidth]; ok {
127+ return cached
128+ }
129+
130+ // Not cached - render now
131+ var rendered string
132+ if m.isMarkdown {
133+ renderer := common.MarkdownRenderer(m.sty, cappedWidth)
134+ result, err := renderer.Render(m.content)
135+ if err != nil {
136+ rendered = m.content
137+ } else {
138+ rendered = strings.TrimSuffix(result, "\n")
139+ }
140+ } else {
141+ rendered = m.content
142+ }
143+
144+ // Cache the result
145+ m.cache[cappedWidth] = rendered
146+ return rendered
147+}
148+
149+// SetHighlight implements list.Highlightable and extends BaseHighlightable.
150+func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) {
151+ m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
152+ // Clear cache when highlight changes
153+ m.cache = make(map[int]string)
154+}
155+
156+// ToolCallItem represents a rendered tool call with its header and content.
157+type ToolCallItem struct {
158+ list.BaseFocusable
159+ list.BaseHighlightable
160+ id string
161+ toolCall message.ToolCall
162+ toolResult message.ToolResult
163+ cancelled bool
164+ isNested bool
165+ maxWidth int
166+ cache map[int]cachedToolRender // Cache for rendered content at different widths
167+ cacheKey string // Key to invalidate cache when content changes
168+ sty *styles.Styles
169+}
170+
171+// cachedToolRender stores both the rendered string and its height.
172+type cachedToolRender struct {
173+ content string
174+ height int
175+}
176+
177+// NewToolCallItem creates a new tool call item.
178+func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
179+ t := &ToolCallItem{
180+ id: id,
181+ toolCall: toolCall,
182+ toolResult: toolResult,
183+ cancelled: cancelled,
184+ isNested: isNested,
185+ maxWidth: 120,
186+ cache: make(map[int]cachedToolRender),
187+ cacheKey: generateCacheKey(toolCall, toolResult, cancelled),
188+ sty: sty,
189+ }
190+ t.InitHighlight()
191+ return t
192+}
193+
194+// generateCacheKey creates a key that changes when tool call content changes.
195+func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
196+ // Simple key based on result state - when result arrives or changes, key changes
197+ return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
198+}
199+
200+// ID implements Identifiable.
201+func (t *ToolCallItem) ID() string {
202+ return t.id
203+}
204+
205+// Height implements list.Item.
206+func (t *ToolCallItem) Height(width int) int {
207+ // Calculate content width accounting for frame size
208+ contentWidth := width
209+ frameSize := 0
210+ if style := t.CurrentStyle(); style != nil {
211+ frameSize = style.GetHorizontalFrameSize()
212+ contentWidth -= frameSize
213+ }
214+
215+ cached := t.renderCached(contentWidth)
216+
217+ // Add frame size to height if needed
218+ height := cached.height
219+ if frameSize > 0 {
220+ // Frame can add to height (borders, padding)
221+ if style := t.CurrentStyle(); style != nil {
222+ // Quick render to get accurate height with frame
223+ rendered := style.Render(cached.content)
224+ height = strings.Count(rendered, "\n") + 1
225+ }
226+ }
227+
228+ return height
229+}
230+
231+// Draw implements list.Item.
232+func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
233+ width := area.Dx()
234+ height := area.Dy()
235+
236+ // Calculate content width accounting for frame size
237+ contentWidth := width
238+ style := t.CurrentStyle()
239+ if style != nil {
240+ contentWidth -= style.GetHorizontalFrameSize()
241+ }
242+
243+ cached := t.renderCached(contentWidth)
244+ rendered := cached.content
245+
246+ if style != nil {
247+ rendered = style.Render(rendered)
248+ }
249+
250+ tempBuf := uv.NewScreenBuffer(width, height)
251+ styled := uv.NewStyledString(rendered)
252+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
253+
254+ t.ApplyHighlight(&tempBuf, width, height, style)
255+ tempBuf.Draw(scr, area)
256+}
257+
258+// renderCached renders the tool call at the given width with caching.
259+func (t *ToolCallItem) renderCached(width int) cachedToolRender {
260+ cappedWidth := min(width, t.maxWidth)
261+
262+ // Check if we have a valid cache entry
263+ if cached, ok := t.cache[cappedWidth]; ok {
264+ return cached
265+ }
266+
267+ // Render the tool call
268+ ctx := &toolrender.RenderContext{
269+ Call: t.toolCall,
270+ Result: t.toolResult,
271+ Cancelled: t.cancelled,
272+ IsNested: t.isNested,
273+ Width: cappedWidth,
274+ Styles: t.sty,
275+ }
276+
277+ rendered := toolrender.Render(ctx)
278+ height := strings.Count(rendered, "\n") + 1
279+
280+ cached := cachedToolRender{
281+ content: rendered,
282+ height: height,
283+ }
284+ t.cache[cappedWidth] = cached
285+ return cached
286+}
287+
288+// SetHighlight implements list.Highlightable.
289+func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
290+ t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
291+ // Clear cache when highlight changes
292+ t.cache = make(map[int]cachedToolRender)
293+}
294+
295+// UpdateResult updates the tool result and invalidates the cache if needed.
296+func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
297+ newKey := generateCacheKey(t.toolCall, result, t.cancelled)
298+ if newKey != t.cacheKey {
299+ t.toolResult = result
300+ t.cacheKey = newKey
301+ t.cache = make(map[int]cachedToolRender)
302+ }
303+}
304+
305+// AttachmentItem represents a file attachment in a user message.
306+type AttachmentItem struct {
307+ list.BaseFocusable
308+ list.BaseHighlightable
309+ id string
310+ filename string
311+ path string
312+ sty *styles.Styles
313+}
314+
315+// NewAttachmentItem creates a new attachment item.
316+func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
317+ a := &AttachmentItem{
318+ id: id,
319+ filename: filename,
320+ path: path,
321+ sty: sty,
322+ }
323+ a.InitHighlight()
324+ return a
325+}
326+
327+// ID implements Identifiable.
328+func (a *AttachmentItem) ID() string {
329+ return a.id
330+}
331+
332+// Height implements list.Item.
333+func (a *AttachmentItem) Height(width int) int {
334+ return 1
335+}
336+
337+// Draw implements list.Item.
338+func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
339+ width := area.Dx()
340+ height := area.Dy()
341+
342+ // Calculate content width accounting for frame size
343+ contentWidth := width
344+ style := a.CurrentStyle()
345+ if style != nil {
346+ contentWidth -= style.GetHorizontalFrameSize()
347+ }
348+
349+ const maxFilenameWidth = 10
350+ content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
351+ " %s %s ",
352+ styles.DocumentIcon,
353+ ansi.Truncate(a.filename, maxFilenameWidth, "..."),
354+ ))
355+
356+ if style != nil {
357+ content = style.Render(content)
358+ }
359+
360+ tempBuf := uv.NewScreenBuffer(width, height)
361+ styled := uv.NewStyledString(content)
362+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
363+
364+ a.ApplyHighlight(&tempBuf, width, height, style)
365+ tempBuf.Draw(scr, area)
366+}
367+
368+// ThinkingItem represents thinking/reasoning content in assistant messages.
369+type ThinkingItem struct {
370+ list.BaseFocusable
371+ list.BaseHighlightable
372+ id string
373+ thinking string
374+ duration time.Duration
375+ finished bool
376+ maxWidth int
377+ cache map[int]string
378+ sty *styles.Styles
379+}
380+
381+// NewThinkingItem creates a new thinking item.
382+func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
383+ t := &ThinkingItem{
384+ id: id,
385+ thinking: thinking,
386+ duration: duration,
387+ finished: finished,
388+ maxWidth: 120,
389+ cache: make(map[int]string),
390+ sty: sty,
391+ }
392+ t.InitHighlight()
393+ return t
394+}
395+
396+// ID implements Identifiable.
397+func (t *ThinkingItem) ID() string {
398+ return t.id
399+}
400+
401+// Height implements list.Item.
402+func (t *ThinkingItem) Height(width int) int {
403+ // Calculate content width accounting for frame size
404+ contentWidth := width
405+ if style := t.CurrentStyle(); style != nil {
406+ contentWidth -= style.GetHorizontalFrameSize()
407+ }
408+
409+ rendered := t.render(contentWidth)
410+ return strings.Count(rendered, "\n") + 1
411+}
412+
413+// Draw implements list.Item.
414+func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
415+ width := area.Dx()
416+ height := area.Dy()
417+
418+ // Calculate content width accounting for frame size
419+ contentWidth := width
420+ style := t.CurrentStyle()
421+ if style != nil {
422+ contentWidth -= style.GetHorizontalFrameSize()
423+ }
424+
425+ rendered := t.render(contentWidth)
426+
427+ if style != nil {
428+ rendered = style.Render(rendered)
429+ }
430+
431+ tempBuf := uv.NewScreenBuffer(width, height)
432+ styled := uv.NewStyledString(rendered)
433+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
434+
435+ t.ApplyHighlight(&tempBuf, width, height, style)
436+ tempBuf.Draw(scr, area)
437+}
438+
439+// render renders the thinking content.
440+func (t *ThinkingItem) render(width int) string {
441+ cappedWidth := min(width, t.maxWidth)
442+
443+ if cached, ok := t.cache[cappedWidth]; ok {
444+ return cached
445+ }
446+
447+ renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
448+ rendered, err := renderer.Render(t.thinking)
449+ if err != nil {
450+ // Fallback to line-by-line rendering
451+ lines := strings.Split(t.thinking, "\n")
452+ var content strings.Builder
453+ lineStyle := t.sty.PanelMuted
454+ for i, line := range lines {
455+ if line == "" {
456+ continue
457+ }
458+ content.WriteString(lineStyle.Width(cappedWidth).Render(line))
459+ if i < len(lines)-1 {
460+ content.WriteString("\n")
461+ }
462+ }
463+ rendered = content.String()
464+ }
465+
466+ fullContent := strings.TrimSpace(rendered)
467+
468+ // Add footer if finished
469+ if t.finished && t.duration > 0 {
470+ footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
471+ fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
472+ }
473+
474+ result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
475+
476+ t.cache[cappedWidth] = result
477+ return result
478+}
479+
480+// SetHighlight implements list.Highlightable.
481+func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
482+ t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
483+ t.cache = make(map[int]string)
484+}
485+
486+// SectionHeaderItem represents a section header (e.g., assistant info).
487+type SectionHeaderItem struct {
488+ list.BaseFocusable
489+ list.BaseHighlightable
490+ id string
491+ modelName string
492+ duration time.Duration
493+ isSectionHeader bool
494+ sty *styles.Styles
495+}
496+
497+// NewSectionHeaderItem creates a new section header item.
498+func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
499+ s := &SectionHeaderItem{
500+ id: id,
501+ modelName: modelName,
502+ duration: duration,
503+ isSectionHeader: true,
504+ sty: sty,
505+ }
506+ s.InitHighlight()
507+ return s
508+}
509+
510+// ID implements Identifiable.
511+func (s *SectionHeaderItem) ID() string {
512+ return s.id
513+}
514+
515+// IsSectionHeader returns true if this is a section header.
516+func (s *SectionHeaderItem) IsSectionHeader() bool {
517+ return s.isSectionHeader
518+}
519+
520+// Height implements list.Item.
521+func (s *SectionHeaderItem) Height(width int) int {
522+ return 1
523+}
524+
525+// Draw implements list.Item.
526+func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
527+ width := area.Dx()
528+ height := area.Dy()
529+
530+ // Calculate content width accounting for frame size
531+ contentWidth := width
532+ style := s.CurrentStyle()
533+ if style != nil {
534+ contentWidth -= style.GetHorizontalFrameSize()
535+ }
536+
537+ infoMsg := s.sty.Subtle.Render(s.duration.String())
538+ icon := s.sty.Subtle.Render(styles.ModelIcon)
539+ modelFormatted := s.sty.Muted.Render(s.modelName)
540+ content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
541+
542+ content = s.sty.Chat.Message.SectionHeader.Render(content)
543+
544+ if style != nil {
545+ content = style.Render(content)
546+ }
547+
548+ tempBuf := uv.NewScreenBuffer(width, height)
549+ styled := uv.NewStyledString(content)
550+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
551+
552+ s.ApplyHighlight(&tempBuf, width, height, style)
553+ tempBuf.Draw(scr, area)
554+}
555+
556+// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
557+// all parts of the message as [MessageItem]s.
558+//
559+// For assistant messages with tool calls, pass a toolResults map to link results.
560+// Use BuildToolResultMap to create this map from all messages in a session.
561+func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
562+ sty := styles.DefaultStyles()
563+ var items []MessageItem
564+
565+ // Skip tool result messages - they're displayed inline with tool calls
566+ if msg.Role == message.Tool {
567+ return items
568+ }
569+
570+ // Create base styles for the message
571+ var focusStyle, blurStyle lipgloss.Style
572+ if msg.Role == message.User {
573+ focusStyle = sty.Chat.Message.UserFocused
574+ blurStyle = sty.Chat.Message.UserBlurred
575+ } else {
576+ focusStyle = sty.Chat.Message.AssistantFocused
577+ blurStyle = sty.Chat.Message.AssistantBlurred
578+ }
579+
580+ // Process user messages
581+ if msg.Role == message.User {
582+ // Add main text content
583+ content := msg.Content().String()
584+ if content != "" {
585+ item := NewMessageContentItem(
586+ fmt.Sprintf("%s-content", msg.ID),
587+ content,
588+ true, // User messages are markdown
589+ &sty,
590+ )
591+ item.SetFocusStyles(&focusStyle, &blurStyle)
592+ items = append(items, item)
593+ }
594+
595+ // Add attachments
596+ for i, attachment := range msg.BinaryContent() {
597+ filename := filepath.Base(attachment.Path)
598+ item := NewAttachmentItem(
599+ fmt.Sprintf("%s-attachment-%d", msg.ID, i),
600+ filename,
601+ attachment.Path,
602+ &sty,
603+ )
604+ item.SetFocusStyles(&focusStyle, &blurStyle)
605+ items = append(items, item)
606+ }
607+
608+ return items
609+ }
610+
611+ // Process assistant messages
612+ if msg.Role == message.Assistant {
613+ // Check if we need to add a section header
614+ finishData := msg.FinishPart()
615+ if finishData != nil && msg.Model != "" {
616+ model := config.Get().GetModel(msg.Provider, msg.Model)
617+ modelName := "Unknown Model"
618+ if model != nil {
619+ modelName = model.Name
620+ }
621+
622+ // Calculate duration (this would need the last user message time)
623+ duration := time.Duration(0)
624+ if finishData.Time > 0 {
625+ duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
626+ }
627+
628+ header := NewSectionHeaderItem(
629+ fmt.Sprintf("%s-header", msg.ID),
630+ modelName,
631+ duration,
632+ &sty,
633+ )
634+ items = append(items, header)
635+ }
636+
637+ // Add thinking content if present
638+ reasoning := msg.ReasoningContent()
639+ if strings.TrimSpace(reasoning.Thinking) != "" {
640+ duration := time.Duration(0)
641+ if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
642+ duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
643+ }
644+
645+ item := NewThinkingItem(
646+ fmt.Sprintf("%s-thinking", msg.ID),
647+ reasoning.Thinking,
648+ duration,
649+ reasoning.FinishedAt > 0,
650+ &sty,
651+ )
652+ item.SetFocusStyles(&focusStyle, &blurStyle)
653+ items = append(items, item)
654+ }
655+
656+ // Add main text content
657+ content := msg.Content().String()
658+ finished := msg.IsFinished()
659+
660+ // Handle special finish states
661+ if finished && content == "" && finishData != nil {
662+ switch finishData.Reason {
663+ case message.FinishReasonEndTurn:
664+ // No content to show
665+ case message.FinishReasonCanceled:
666+ item := NewMessageContentItem(
667+ fmt.Sprintf("%s-content", msg.ID),
668+ "*Canceled*",
669+ true,
670+ &sty,
671+ )
672+ item.SetFocusStyles(&focusStyle, &blurStyle)
673+ items = append(items, item)
674+ case message.FinishReasonError:
675+ // Render error
676+ errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
677+ truncated := ansi.Truncate(finishData.Message, 100, "...")
678+ title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
679+ details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
680+ errorContent := fmt.Sprintf("%s\n\n%s", title, details)
681+
682+ item := NewMessageContentItem(
683+ fmt.Sprintf("%s-error", msg.ID),
684+ errorContent,
685+ false,
686+ &sty,
687+ )
688+ item.SetFocusStyles(&focusStyle, &blurStyle)
689+ items = append(items, item)
690+ }
691+ } else if content != "" {
692+ item := NewMessageContentItem(
693+ fmt.Sprintf("%s-content", msg.ID),
694+ content,
695+ true, // Assistant messages are markdown
696+ &sty,
697+ )
698+ item.SetFocusStyles(&focusStyle, &blurStyle)
699+ items = append(items, item)
700+ }
701+
702+ // Add tool calls
703+ toolCalls := msg.ToolCalls()
704+
705+ // Use passed-in tool results map (if nil, use empty map)
706+ resultMap := toolResults
707+ if resultMap == nil {
708+ resultMap = make(map[string]message.ToolResult)
709+ }
710+
711+ for _, tc := range toolCalls {
712+ result, hasResult := resultMap[tc.ID]
713+ if !hasResult {
714+ result = message.ToolResult{}
715+ }
716+
717+ item := NewToolCallItem(
718+ fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
719+ tc,
720+ result,
721+ false, // cancelled state would need to be tracked separately
722+ false, // nested state would be detected from tool results
723+ &sty,
724+ )
725+
726+ // Tool calls use muted style with optional focus border
727+ item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
728+
729+ items = append(items, item)
730+ }
731+
732+ return items
733+ }
734+
735+ return items
736+}
737+
738+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
739+// Tool result messages (role == message.Tool) contain the results that should be linked
740+// to tool calls in assistant messages.
741+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
742+ resultMap := make(map[string]message.ToolResult)
743+ for _, msg := range messages {
744+ if msg.Role == message.Tool {
745+ for _, result := range msg.ToolResults() {
746+ if result.ToolCallID != "" {
747+ resultMap[result.ToolCallID] = result
748+ }
749+ }
750+ }
751+ }
752+ return resultMap
753+}
internal/ui/model/ui.go+15−2
@@ -18,6 +18,7 @@ import (
1818 "github.com/charmbracelet/crush/internal/app"
1919 "github.com/charmbracelet/crush/internal/config"
2020 "github.com/charmbracelet/crush/internal/history"
21+ "github.com/charmbracelet/crush/internal/message"
2122 "github.com/charmbracelet/crush/internal/pubsub"
2223 "github.com/charmbracelet/crush/internal/session"
2324 "github.com/charmbracelet/crush/internal/ui/common"
@@ -195,9 +196,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
195196 m.session = &msg.sess
196197 // Load the last 20 messages from this session.
197198 msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
198− for _, message := range msgs {
199− m.chat.AppendMessage(message)
199+
200+ // Build tool result map to link tool calls with their results
201+ msgPtrs := make([]*message.Message, len(msgs))
202+ for i := range msgs {
203+ msgPtrs[i] = &msgs[i]
204+ }
205+ toolResultMap := BuildToolResultMap(msgPtrs)
206+
207+ // Add messages to chat with linked tool results
208+ items := make([]MessageItem, 0, len(msgs)*2)
209+ for _, msg := range msgPtrs {
210+ items = append(items, GetMessageItems(msg, toolResultMap)...)
200211 }
212+ m.chat.AppendMessages(items...)
213+
201214 // Notify that session loading is done to scroll to bottom. This is
202215 // needed because we need to draw the chat list first before we can
203216 // scroll to bottom.
internal/ui/styles/styles.go+267−19
@@ -9,6 +9,7 @@ import (
99 "charm.land/bubbles/v2/textinput"
1010 tea "charm.land/bubbletea/v2"
1111 "charm.land/lipgloss/v2"
12+ "github.com/alecthomas/chroma/v2"
1213 "github.com/charmbracelet/crush/internal/tui/exp/diffview"
1314 "github.com/charmbracelet/glamour/v2/ansi"
1415 "github.com/charmbracelet/x/exp/charmtone"
@@ -117,6 +118,7 @@ type Styles struct {
117118
118119 // Background
119120 Background color.Color
121+
120122 // Logo
121123 LogoFieldColor color.Color
122124 LogoTitleColorA color.Color
@@ -124,6 +126,31 @@ type Styles struct {
124126 LogoCharmColor color.Color
125127 LogoVersionColor color.Color
126128
129+ // Colors - semantic colors for tool rendering.
130+ Primary color.Color
131+ Secondary color.Color
132+ Tertiary color.Color
133+ BgBase color.Color
134+ BgBaseLighter color.Color
135+ BgSubtle color.Color
136+ BgOverlay color.Color
137+ FgBase color.Color
138+ FgMuted color.Color
139+ FgHalfMuted color.Color
140+ FgSubtle color.Color
141+ Border color.Color
142+ BorderColor color.Color // Border focus color
143+ Warning color.Color
144+ Info color.Color
145+ White color.Color
146+ BlueLight color.Color
147+ Blue color.Color
148+ Green color.Color
149+ GreenDark color.Color
150+ Red color.Color
151+ RedDark color.Color
152+ Yellow color.Color
153+
127154 // Section Title
128155 Section struct {
129156 Title lipgloss.Style
@@ -154,16 +181,120 @@ type Styles struct {
154181
155182 // Chat
156183 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
184+ // Message item styles
185+ Message struct {
186+ UserBlurred lipgloss.Style
187+ UserFocused lipgloss.Style
188+ AssistantBlurred lipgloss.Style
189+ AssistantFocused lipgloss.Style
190+ NoContent lipgloss.Style
191+ Thinking lipgloss.Style
192+ ErrorTag lipgloss.Style
193+ ErrorTitle lipgloss.Style
194+ ErrorDetails lipgloss.Style
195+ Attachment lipgloss.Style
196+ ToolCallFocused lipgloss.Style
197+ ToolCallBlurred lipgloss.Style
198+ ThinkingFooter lipgloss.Style
199+ SectionHeader lipgloss.Style
200+ }
201+ }
202+
203+ // Tool - styles for tool call rendering
204+ Tool struct {
205+ // Icon styles with tool status
206+ IconPending lipgloss.Style // Pending operation icon
207+ IconSuccess lipgloss.Style // Successful operation icon
208+ IconError lipgloss.Style // Error operation icon
209+ IconCancelled lipgloss.Style // Cancelled operation icon
210+
211+ // Tool name styles
212+ NameNormal lipgloss.Style // Normal tool name
213+ NameNested lipgloss.Style // Nested tool name
214+
215+ // Parameter list styles
216+ ParamMain lipgloss.Style // Main parameter
217+ ParamKey lipgloss.Style // Parameter keys
218+
219+ // Content rendering styles
220+ ContentLine lipgloss.Style // Individual content line with background and width
221+ ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
222+ ContentCodeLine lipgloss.Style // Code line with background and width
223+ ContentCodeBg color.Color // Background color for syntax highlighting
224+ BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2))
225+
226+ // Deprecated - kept for backward compatibility
227+ ContentBg lipgloss.Style // Content background
228+ ContentText lipgloss.Style // Content text
229+ ContentLineNumber lipgloss.Style // Line numbers in code
230+
231+ // State message styles
232+ StateWaiting lipgloss.Style // "Waiting for tool response..."
233+ StateCancelled lipgloss.Style // "Canceled."
234+
235+ // Error styles
236+ ErrorTag lipgloss.Style // ERROR tag
237+ ErrorMessage lipgloss.Style // Error message text
238+
239+ // Diff styles
240+ DiffTruncation lipgloss.Style // Diff truncation message with padding
241+
242+ // Multi-edit note styles
243+ NoteTag lipgloss.Style // NOTE tag (yellow background)
244+ NoteMessage lipgloss.Style // Note message text
245+
246+ // Job header styles (for bash jobs)
247+ JobIconPending lipgloss.Style // Pending job icon (green dark)
248+ JobIconError lipgloss.Style // Error job icon (red dark)
249+ JobIconSuccess lipgloss.Style // Success job icon (green)
250+ JobToolName lipgloss.Style // Job tool name "Bash" (blue)
251+ JobAction lipgloss.Style // Action text (Start, Output, Kill)
252+ JobPID lipgloss.Style // PID text
253+ JobDescription lipgloss.Style // Description text
254+
255+ // Agent task styles
256+ AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
257+ AgentPrompt lipgloss.Style // Agent prompt text
258+ }
259+}
260+
261+// ChromaTheme converts the current markdown chroma styles to a chroma
262+// StyleEntries map.
263+func (s *Styles) ChromaTheme() chroma.StyleEntries {
264+ rules := s.Markdown.CodeBlock
265+
266+ return chroma.StyleEntries{
267+ chroma.Text: chromaStyle(rules.Chroma.Text),
268+ chroma.Error: chromaStyle(rules.Chroma.Error),
269+ chroma.Comment: chromaStyle(rules.Chroma.Comment),
270+ chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
271+ chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
272+ chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
273+ chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
274+ chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
275+ chroma.Operator: chromaStyle(rules.Chroma.Operator),
276+ chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
277+ chroma.Name: chromaStyle(rules.Chroma.Name),
278+ chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
279+ chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
280+ chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
281+ chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
282+ chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
283+ chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
284+ chroma.NameException: chromaStyle(rules.Chroma.NameException),
285+ chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
286+ chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
287+ chroma.Literal: chromaStyle(rules.Chroma.Literal),
288+ chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
289+ chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
290+ chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
291+ chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
292+ chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
293+ chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
294+ chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
295+ chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
296+ chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
297+ chroma.Background: chromaStyle(rules.Chroma.Background),
167298 }
168299}
169300
@@ -202,6 +333,7 @@ func DefaultStyles() Styles {
202333 blue = charmtone.Malibu
203334
204335 // yellow = charmtone.Mustard
336+ yellow = charmtone.Mustard
205337 // citron = charmtone.Citron
206338
207339 green = charmtone.Julep
@@ -222,6 +354,31 @@ func DefaultStyles() Styles {
222354
223355 s.Background = bgBase
224356
357+ // Populate color fields
358+ s.Primary = primary
359+ s.Secondary = secondary
360+ s.Tertiary = tertiary
361+ s.BgBase = bgBase
362+ s.BgBaseLighter = bgBaseLighter
363+ s.BgSubtle = bgSubtle
364+ s.BgOverlay = bgOverlay
365+ s.FgBase = fgBase
366+ s.FgMuted = fgMuted
367+ s.FgHalfMuted = fgHalfMuted
368+ s.FgSubtle = fgSubtle
369+ s.Border = border
370+ s.BorderColor = borderFocus
371+ s.Warning = warning
372+ s.Info = info
373+ s.White = white
374+ s.BlueLight = blueLight
375+ s.Blue = blue
376+ s.Green = green
377+ s.GreenDark = greenDark
378+ s.Red = red
379+ s.RedDark = redDark
380+ s.Yellow = yellow
381+
225382 s.TextInput = textinput.Styles{
226383 Focused: textinput.StyleState{
227384 Text: base,
@@ -580,6 +737,54 @@ func DefaultStyles() Styles {
580737 s.ToolCallCancelled = s.Muted.SetString(ToolPending)
581738 s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
582739
740+ // Tool rendering styles
741+ s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
742+ s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
743+ s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
744+ s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
745+
746+ s.Tool.NameNormal = base.Foreground(blue)
747+ s.Tool.NameNested = base.Foreground(fgHalfMuted)
748+
749+ s.Tool.ParamMain = s.Subtle
750+ s.Tool.ParamKey = s.Subtle
751+
752+ // Content rendering - prepared styles that accept width parameter
753+ s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
754+ s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
755+ s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
756+ s.Tool.ContentCodeBg = bgBase
757+ s.Tool.BodyPadding = base.PaddingLeft(2)
758+
759+ // Deprecated - kept for backward compatibility
760+ s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
761+ s.Tool.ContentText = s.Muted
762+ s.Tool.ContentLineNumber = s.Subtle
763+
764+ s.Tool.StateWaiting = base.Foreground(fgSubtle)
765+ s.Tool.StateCancelled = base.Foreground(fgSubtle)
766+
767+ s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
768+ s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
769+
770+ // Diff and multi-edit styles
771+ s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
772+ s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white)
773+ s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
774+
775+ // Job header styles
776+ s.Tool.JobIconPending = base.Foreground(greenDark)
777+ s.Tool.JobIconError = base.Foreground(redDark)
778+ s.Tool.JobIconSuccess = base.Foreground(green)
779+ s.Tool.JobToolName = base.Foreground(blue)
780+ s.Tool.JobAction = base.Foreground(fgHalfMuted)
781+ s.Tool.JobPID = s.Subtle
782+ s.Tool.JobDescription = s.Subtle
783+
784+ // Agent task styles
785+ s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
786+ s.Tool.AgentPrompt = s.Muted
787+
583788 // Buttons
584789 s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
585790 s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -633,19 +838,29 @@ func DefaultStyles() Styles {
633838 Left: "▌",
634839 }
635840
636− s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase)
637− s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
841+ s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
842+ s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
638843 BorderForeground(primary).BorderStyle(normalBorder)
639− s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
844+ s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
640845 BorderForeground(primary).BorderStyle(messageFocussedBorder)
641− s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2)
642− s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
846+ s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
847+ s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
643848 BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
644− s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10)
645− s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
849+ s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
850+ s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
646851 Background(red).Foreground(white)
647− s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
648− s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
852+ s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
853+ s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
854+
855+ // Message item styles
856+ s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
857+ s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
858+ BorderStyle(messageFocussedBorder).
859+ BorderLeft(true).
860+ BorderForeground(greenDark)
861+ s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
862+ s.Chat.Message.ThinkingFooter = s.Base
863+ s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
649864
650865 // Text selection.
651866 s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
@@ -657,3 +872,36 @@ func DefaultStyles() Styles {
657872func boolPtr(b bool) *bool { return &b }
658873func stringPtr(s string) *string { return &s }
659874func uintPtr(u uint) *uint { return &u }
875+func chromaStyle(style ansi.StylePrimitive) string {
876+ var s string
877+
878+ if style.Color != nil {
879+ s = *style.Color
880+ }
881+ if style.BackgroundColor != nil {
882+ if s != "" {
883+ s += " "
884+ }
885+ s += "bg:" + *style.BackgroundColor
886+ }
887+ if style.Italic != nil && *style.Italic {
888+ if s != "" {
889+ s += " "
890+ }
891+ s += "italic"
892+ }
893+ if style.Bold != nil && *style.Bold {
894+ if s != "" {
895+ s += " "
896+ }
897+ s += "bold"
898+ }
899+ if style.Underline != nil && *style.Underline {
900+ if s != "" {
901+ s += " "
902+ }
903+ s += "underline"
904+ }
905+
906+ return s
907+}
internal/ui/toolrender/render.gonew file+889
@@ -0,0 +1,889 @@
1+package toolrender
2+
3+import (
4+ "cmp"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "charm.land/lipgloss/v2"
10+ "charm.land/lipgloss/v2/tree"
11+ "github.com/charmbracelet/crush/internal/agent"
12+ "github.com/charmbracelet/crush/internal/agent/tools"
13+ "github.com/charmbracelet/crush/internal/ansiext"
14+ "github.com/charmbracelet/crush/internal/fsext"
15+ "github.com/charmbracelet/crush/internal/message"
16+ "github.com/charmbracelet/crush/internal/ui/common"
17+ "github.com/charmbracelet/crush/internal/ui/styles"
18+ "github.com/charmbracelet/x/ansi"
19+)
20+
21+// responseContextHeight limits the number of lines displayed in tool output.
22+const responseContextHeight = 10
23+
24+// RenderContext provides the context needed for rendering a tool call.
25+type RenderContext struct {
26+ Call message.ToolCall
27+ Result message.ToolResult
28+ Cancelled bool
29+ IsNested bool
30+ Width int
31+ Styles *styles.Styles
32+}
33+
34+// TextWidth returns the available width for content accounting for borders.
35+func (rc *RenderContext) TextWidth() int {
36+ if rc.IsNested {
37+ return rc.Width - 6
38+ }
39+ return rc.Width - 5
40+}
41+
42+// Fit truncates content to fit within the specified width with ellipsis.
43+func (rc *RenderContext) Fit(content string, width int) string {
44+ lineStyle := rc.Styles.Muted
45+ dots := lineStyle.Render("…")
46+ return ansi.Truncate(content, width, dots)
47+}
48+
49+// Render renders a tool call using the appropriate renderer based on tool name.
50+func Render(ctx *RenderContext) string {
51+ switch ctx.Call.Name {
52+ case tools.ViewToolName:
53+ return renderView(ctx)
54+ case tools.EditToolName:
55+ return renderEdit(ctx)
56+ case tools.MultiEditToolName:
57+ return renderMultiEdit(ctx)
58+ case tools.WriteToolName:
59+ return renderWrite(ctx)
60+ case tools.BashToolName:
61+ return renderBash(ctx)
62+ case tools.JobOutputToolName:
63+ return renderJobOutput(ctx)
64+ case tools.JobKillToolName:
65+ return renderJobKill(ctx)
66+ case tools.FetchToolName:
67+ return renderSimpleFetch(ctx)
68+ case tools.AgenticFetchToolName:
69+ return renderAgenticFetch(ctx)
70+ case tools.WebFetchToolName:
71+ return renderWebFetch(ctx)
72+ case tools.DownloadToolName:
73+ return renderDownload(ctx)
74+ case tools.GlobToolName:
75+ return renderGlob(ctx)
76+ case tools.GrepToolName:
77+ return renderGrep(ctx)
78+ case tools.LSToolName:
79+ return renderLS(ctx)
80+ case tools.SourcegraphToolName:
81+ return renderSourcegraph(ctx)
82+ case tools.DiagnosticsToolName:
83+ return renderDiagnostics(ctx)
84+ case agent.AgentToolName:
85+ return renderAgent(ctx)
86+ default:
87+ return renderGeneric(ctx)
88+ }
89+}
90+
91+// Helper functions
92+
93+func unmarshalParams(input string, target any) error {
94+ return json.Unmarshal([]byte(input), target)
95+}
96+
97+type paramBuilder struct {
98+ args []string
99+}
100+
101+func newParamBuilder() *paramBuilder {
102+ return &paramBuilder{args: make([]string, 0)}
103+}
104+
105+func (pb *paramBuilder) addMain(value string) *paramBuilder {
106+ if value != "" {
107+ pb.args = append(pb.args, value)
108+ }
109+ return pb
110+}
111+
112+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
113+ if value != "" {
114+ pb.args = append(pb.args, key, value)
115+ }
116+ return pb
117+}
118+
119+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
120+ if value {
121+ pb.args = append(pb.args, key, "true")
122+ }
123+ return pb
124+}
125+
126+func (pb *paramBuilder) build() []string {
127+ return pb.args
128+}
129+
130+func formatNonZero[T comparable](value T) string {
131+ var zero T
132+ if value == zero {
133+ return ""
134+ }
135+ return fmt.Sprintf("%v", value)
136+}
137+
138+func makeHeader(ctx *RenderContext, toolName string, args []string) string {
139+ if ctx.IsNested {
140+ return makeNestedHeader(ctx, toolName, args)
141+ }
142+ s := ctx.Styles
143+ var icon string
144+ if ctx.Result.ToolCallID != "" {
145+ if ctx.Result.IsError {
146+ icon = s.Tool.IconError.Render()
147+ } else {
148+ icon = s.Tool.IconSuccess.Render()
149+ }
150+ } else if ctx.Cancelled {
151+ icon = s.Tool.IconCancelled.Render()
152+ } else {
153+ icon = s.Tool.IconPending.Render()
154+ }
155+ tool := s.Tool.NameNormal.Render(toolName)
156+ prefix := fmt.Sprintf("%s %s ", icon, tool)
157+ return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
158+}
159+
160+func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
161+ s := ctx.Styles
162+ var icon string
163+ if ctx.Result.ToolCallID != "" {
164+ if ctx.Result.IsError {
165+ icon = s.Tool.IconError.Render()
166+ } else {
167+ icon = s.Tool.IconSuccess.Render()
168+ }
169+ } else if ctx.Cancelled {
170+ icon = s.Tool.IconCancelled.Render()
171+ } else {
172+ icon = s.Tool.IconPending.Render()
173+ }
174+ tool := s.Tool.NameNested.Render(toolName)
175+ prefix := fmt.Sprintf("%s %s ", icon, tool)
176+ return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
177+}
178+
179+func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
180+ s := ctx.Styles
181+ if len(params) == 0 {
182+ return ""
183+ }
184+ mainParam := params[0]
185+ if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
186+ mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
187+ }
188+
189+ if len(params) == 1 {
190+ return s.Tool.ParamMain.Render(mainParam)
191+ }
192+ otherParams := params[1:]
193+ if len(otherParams)%2 != 0 {
194+ otherParams = append(otherParams, "")
195+ }
196+ parts := make([]string, 0, len(otherParams)/2)
197+ for i := 0; i < len(otherParams); i += 2 {
198+ key := otherParams[i]
199+ value := otherParams[i+1]
200+ if value == "" {
201+ continue
202+ }
203+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
204+ }
205+
206+ partsRendered := strings.Join(parts, ", ")
207+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
208+ if remainingWidth < 30 {
209+ return s.Tool.ParamMain.Render(mainParam)
210+ }
211+
212+ if len(parts) > 0 {
213+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
214+ }
215+
216+ return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
217+}
218+
219+func earlyState(ctx *RenderContext, header string) (string, bool) {
220+ s := ctx.Styles
221+ message := ""
222+ switch {
223+ case ctx.Result.IsError:
224+ message = renderToolError(ctx)
225+ case ctx.Cancelled:
226+ message = s.Tool.StateCancelled.Render("Canceled.")
227+ case ctx.Result.ToolCallID == "":
228+ message = s.Tool.StateWaiting.Render("Waiting for tool response...")
229+ default:
230+ return "", false
231+ }
232+
233+ message = s.Tool.BodyPadding.Render(message)
234+ return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
235+}
236+
237+func renderToolError(ctx *RenderContext) string {
238+ s := ctx.Styles
239+ errTag := s.Tool.ErrorTag.Render("ERROR")
240+ msg := ctx.Result.Content
241+ if msg == "" {
242+ msg = "An error occurred"
243+ }
244+ truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
245+ return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
246+}
247+
248+func joinHeaderBody(ctx *RenderContext, header, body string) string {
249+ s := ctx.Styles
250+ if body == "" {
251+ return header
252+ }
253+ body = s.Tool.BodyPadding.Render(body)
254+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
255+}
256+
257+func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
258+ header := makeHeader(ctx, toolName, args)
259+ if ctx.IsNested {
260+ return header
261+ }
262+ if res, done := earlyState(ctx, header); done {
263+ return res
264+ }
265+ body := contentRenderer()
266+ return joinHeaderBody(ctx, header, body)
267+}
268+
269+func renderError(ctx *RenderContext, message string) string {
270+ s := ctx.Styles
271+ header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
272+ errorTag := s.Tool.ErrorTag.Render("ERROR")
273+ message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
274+ return joinHeaderBody(ctx, header, errorTag+" "+message)
275+}
276+
277+func renderPlainContent(ctx *RenderContext, content string) string {
278+ s := ctx.Styles
279+ content = strings.ReplaceAll(content, "\r\n", "\n")
280+ content = strings.ReplaceAll(content, "\t", " ")
281+ content = strings.TrimSpace(content)
282+ lines := strings.Split(content, "\n")
283+
284+ width := ctx.TextWidth() - 2
285+ var out []string
286+ for i, ln := range lines {
287+ if i >= responseContextHeight {
288+ break
289+ }
290+ ln = ansiext.Escape(ln)
291+ ln = " " + ln
292+ if len(ln) > width {
293+ ln = ctx.Fit(ln, width)
294+ }
295+ out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
296+ }
297+
298+ if len(lines) > responseContextHeight {
299+ out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
300+ }
301+
302+ return strings.Join(out, "\n")
303+}
304+
305+func renderMarkdownContent(ctx *RenderContext, content string) string {
306+ s := ctx.Styles
307+ content = strings.ReplaceAll(content, "\r\n", "\n")
308+ content = strings.ReplaceAll(content, "\t", " ")
309+ content = strings.TrimSpace(content)
310+
311+ width := ctx.TextWidth() - 2
312+ width = min(width, 120)
313+
314+ renderer := common.PlainMarkdownRenderer(width)
315+ rendered, err := renderer.Render(content)
316+ if err != nil {
317+ return renderPlainContent(ctx, content)
318+ }
319+
320+ lines := strings.Split(rendered, "\n")
321+
322+ var out []string
323+ for i, ln := range lines {
324+ if i >= responseContextHeight {
325+ break
326+ }
327+ out = append(out, ln)
328+ }
329+
330+ style := s.Tool.ContentLine
331+ if len(lines) > responseContextHeight {
332+ out = append(out, s.Tool.ContentTruncation.
333+ Width(width-2).
334+ Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
335+ }
336+
337+ return style.Render(strings.Join(out, "\n"))
338+}
339+
340+func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
341+ s := ctx.Styles
342+ content = strings.ReplaceAll(content, "\r\n", "\n")
343+ content = strings.ReplaceAll(content, "\t", " ")
344+ truncated := truncateHeight(content, responseContextHeight)
345+
346+ lines := strings.Split(truncated, "\n")
347+ for i, ln := range lines {
348+ lines[i] = ansiext.Escape(ln)
349+ }
350+
351+ bg := s.Tool.ContentCodeBg
352+ highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
353+ lines = strings.Split(highlighted, "\n")
354+
355+ width := ctx.TextWidth() - 2
356+ gutterWidth := getDigits(offset+len(lines)) + 1
357+
358+ var out []string
359+ for i, ln := range lines {
360+ lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
361+ gutter := s.Subtle.Render(lineNum + " ")
362+ ln = " " + ln
363+ if lipgloss.Width(gutter+ln) > width {
364+ ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
365+ }
366+ out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
367+ }
368+
369+ contentLines := strings.Split(content, "\n")
370+ if len(contentLines) > responseContextHeight {
371+ out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
372+ }
373+
374+ return strings.Join(out, "\n")
375+}
376+
377+func getDigits(n int) int {
378+ if n == 0 {
379+ return 1
380+ }
381+ if n < 0 {
382+ n = -n
383+ }
384+
385+ digits := 0
386+ for n > 0 {
387+ n /= 10
388+ digits++
389+ }
390+
391+ return digits
392+}
393+
394+func truncateHeight(content string, maxLines int) string {
395+ lines := strings.Split(content, "\n")
396+ if len(lines) <= maxLines {
397+ return content
398+ }
399+ return strings.Join(lines[:maxLines], "\n")
400+}
401+
402+func prettifyToolName(name string) string {
403+ switch name {
404+ case "agent":
405+ return "Agent"
406+ case "bash":
407+ return "Bash"
408+ case "job_output":
409+ return "Job: Output"
410+ case "job_kill":
411+ return "Job: Kill"
412+ case "download":
413+ return "Download"
414+ case "edit":
415+ return "Edit"
416+ case "multiedit":
417+ return "Multi-Edit"
418+ case "fetch":
419+ return "Fetch"
420+ case "agentic_fetch":
421+ return "Agentic Fetch"
422+ case "web_fetch":
423+ return "Fetching"
424+ case "glob":
425+ return "Glob"
426+ case "grep":
427+ return "Grep"
428+ case "ls":
429+ return "List"
430+ case "sourcegraph":
431+ return "Sourcegraph"
432+ case "view":
433+ return "View"
434+ case "write":
435+ return "Write"
436+ case "lsp_references":
437+ return "Find References"
438+ case "lsp_diagnostics":
439+ return "Diagnostics"
440+ default:
441+ parts := strings.Split(name, "_")
442+ for i := range parts {
443+ if len(parts[i]) > 0 {
444+ parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
445+ }
446+ }
447+ return strings.Join(parts, " ")
448+ }
449+}
450+
451+// Tool-specific renderers
452+
453+func renderGeneric(ctx *RenderContext) string {
454+ return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
455+ return renderPlainContent(ctx, ctx.Result.Content)
456+ })
457+}
458+
459+func renderView(ctx *RenderContext) string {
460+ var params tools.ViewParams
461+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
462+ return renderError(ctx, "Invalid view parameters")
463+ }
464+
465+ file := fsext.PrettyPath(params.FilePath)
466+ args := newParamBuilder().
467+ addMain(file).
468+ addKeyValue("limit", formatNonZero(params.Limit)).
469+ addKeyValue("offset", formatNonZero(params.Offset)).
470+ build()
471+
472+ return renderWithParams(ctx, "View", args, func() string {
473+ var meta tools.ViewResponseMetadata
474+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
475+ return renderPlainContent(ctx, ctx.Result.Content)
476+ }
477+ return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
478+ })
479+}
480+
481+func renderEdit(ctx *RenderContext) string {
482+ s := ctx.Styles
483+ var params tools.EditParams
484+ var args []string
485+ if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
486+ file := fsext.PrettyPath(params.FilePath)
487+ args = newParamBuilder().addMain(file).build()
488+ }
489+
490+ return renderWithParams(ctx, "Edit", args, func() string {
491+ var meta tools.EditResponseMetadata
492+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
493+ return renderPlainContent(ctx, ctx.Result.Content)
494+ }
495+
496+ formatter := common.DiffFormatter(ctx.Styles).
497+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
498+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
499+ Width(ctx.TextWidth() - 2)
500+ if ctx.TextWidth() > 120 {
501+ formatter = formatter.Split()
502+ }
503+ formatted := formatter.String()
504+ if lipgloss.Height(formatted) > responseContextHeight {
505+ contentLines := strings.Split(formatted, "\n")
506+ truncateMessage := s.Tool.DiffTruncation.
507+ Width(ctx.TextWidth() - 2).
508+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
509+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
510+ }
511+ return formatted
512+ })
513+}
514+
515+func renderMultiEdit(ctx *RenderContext) string {
516+ s := ctx.Styles
517+ var params tools.MultiEditParams
518+ var args []string
519+ if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
520+ file := fsext.PrettyPath(params.FilePath)
521+ args = newParamBuilder().
522+ addMain(file).
523+ addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
524+ build()
525+ }
526+
527+ return renderWithParams(ctx, "Multi-Edit", args, func() string {
528+ var meta tools.MultiEditResponseMetadata
529+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
530+ return renderPlainContent(ctx, ctx.Result.Content)
531+ }
532+
533+ formatter := common.DiffFormatter(ctx.Styles).
534+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
535+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
536+ Width(ctx.TextWidth() - 2)
537+ if ctx.TextWidth() > 120 {
538+ formatter = formatter.Split()
539+ }
540+ formatted := formatter.String()
541+ if lipgloss.Height(formatted) > responseContextHeight {
542+ contentLines := strings.Split(formatted, "\n")
543+ truncateMessage := s.Tool.DiffTruncation.
544+ Width(ctx.TextWidth() - 2).
545+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
546+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
547+ }
548+
549+ // Add note about failed edits if any.
550+ if len(meta.EditsFailed) > 0 {
551+ noteTag := s.Tool.NoteTag.Render("NOTE")
552+ noteMsg := s.Tool.NoteMessage.Render(
553+ fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
554+ formatted = formatted + "\n\n" + noteTag + " " + noteMsg
555+ }
556+
557+ return formatted
558+ })
559+}
560+
561+func renderWrite(ctx *RenderContext) string {
562+ var params tools.WriteParams
563+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
564+ return renderError(ctx, "Invalid write parameters")
565+ }
566+
567+ file := fsext.PrettyPath(params.FilePath)
568+ args := newParamBuilder().addMain(file).build()
569+
570+ return renderWithParams(ctx, "Write", args, func() string {
571+ return renderCodeContent(ctx, params.FilePath, params.Content, 0)
572+ })
573+}
574+
575+func renderBash(ctx *RenderContext) string {
576+ var params tools.BashParams
577+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
578+ return renderError(ctx, "Invalid bash parameters")
579+ }
580+
581+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
582+ cmd = strings.ReplaceAll(cmd, "\t", " ")
583+ args := newParamBuilder().
584+ addMain(cmd).
585+ addFlag("background", params.RunInBackground).
586+ build()
587+
588+ if ctx.Call.Finished {
589+ var meta tools.BashResponseMetadata
590+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
591+ if meta.Background {
592+ description := cmp.Or(meta.Description, params.Command)
593+ width := ctx.TextWidth()
594+ if ctx.IsNested {
595+ width -= 4
596+ }
597+ header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
598+ if ctx.IsNested {
599+ return header
600+ }
601+ if res, done := earlyState(ctx, header); done {
602+ return res
603+ }
604+ content := "Command: " + params.Command + "\n" + ctx.Result.Content
605+ body := renderPlainContent(ctx, content)
606+ return joinHeaderBody(ctx, header, body)
607+ }
608+ }
609+
610+ return renderWithParams(ctx, "Bash", args, func() string {
611+ var meta tools.BashResponseMetadata
612+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
613+ return renderPlainContent(ctx, ctx.Result.Content)
614+ }
615+ if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
616+ meta.Output = ctx.Result.Content
617+ }
618+
619+ if meta.Output == "" {
620+ return ""
621+ }
622+ return renderPlainContent(ctx, meta.Output)
623+ })
624+}
625+
626+func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
627+ s := ctx.Styles
628+ icon := s.Tool.JobIconPending.Render(styles.ToolPending)
629+ if ctx.Result.ToolCallID != "" {
630+ if ctx.Result.IsError {
631+ icon = s.Tool.JobIconError.Render(styles.ToolError)
632+ } else {
633+ icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
634+ }
635+ } else if ctx.Cancelled {
636+ icon = s.Muted.Render(styles.ToolPending)
637+ }
638+
639+ toolName := s.Tool.JobToolName.Render("Bash")
640+ actionPart := s.Tool.JobAction.Render(action)
641+ pidPart := s.Tool.JobPID.Render(pid)
642+
643+ prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
644+ remainingWidth := width - lipgloss.Width(prefix)
645+
646+ descDisplay := ansi.Truncate(description, remainingWidth, "…")
647+ descDisplay = s.Tool.JobDescription.Render(descDisplay)
648+
649+ return prefix + descDisplay
650+}
651+
652+func renderJobOutput(ctx *RenderContext) string {
653+ var params tools.JobOutputParams
654+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
655+ return renderError(ctx, "Invalid job output parameters")
656+ }
657+
658+ width := ctx.TextWidth()
659+ if ctx.IsNested {
660+ width -= 4
661+ }
662+
663+ var meta tools.JobOutputResponseMetadata
664+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
665+ description := cmp.Or(meta.Description, meta.Command)
666+
667+ header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
668+ if ctx.IsNested {
669+ return header
670+ }
671+ if res, done := earlyState(ctx, header); done {
672+ return res
673+ }
674+ body := renderPlainContent(ctx, ctx.Result.Content)
675+ return joinHeaderBody(ctx, header, body)
676+}
677+
678+func renderJobKill(ctx *RenderContext) string {
679+ var params tools.JobKillParams
680+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
681+ return renderError(ctx, "Invalid job kill parameters")
682+ }
683+
684+ width := ctx.TextWidth()
685+ if ctx.IsNested {
686+ width -= 4
687+ }
688+
689+ var meta tools.JobKillResponseMetadata
690+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
691+ description := cmp.Or(meta.Description, meta.Command)
692+
693+ header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
694+ if ctx.IsNested {
695+ return header
696+ }
697+ if res, done := earlyState(ctx, header); done {
698+ return res
699+ }
700+ body := renderPlainContent(ctx, ctx.Result.Content)
701+ return joinHeaderBody(ctx, header, body)
702+}
703+
704+func renderSimpleFetch(ctx *RenderContext) string {
705+ var params tools.FetchParams
706+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
707+ return renderError(ctx, "Invalid fetch parameters")
708+ }
709+
710+ args := newParamBuilder().
711+ addMain(params.URL).
712+ addKeyValue("format", params.Format).
713+ addKeyValue("timeout", formatNonZero(params.Timeout)).
714+ build()
715+
716+ return renderWithParams(ctx, "Fetch", args, func() string {
717+ path := "file." + params.Format
718+ return renderCodeContent(ctx, path, ctx.Result.Content, 0)
719+ })
720+}
721+
722+func renderAgenticFetch(ctx *RenderContext) string {
723+ // TODO: Implement nested tool call rendering with tree.
724+ return renderGeneric(ctx)
725+}
726+
727+func renderWebFetch(ctx *RenderContext) string {
728+ var params tools.WebFetchParams
729+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
730+ return renderError(ctx, "Invalid web fetch parameters")
731+ }
732+
733+ args := newParamBuilder().addMain(params.URL).build()
734+
735+ return renderWithParams(ctx, "Fetching", args, func() string {
736+ return renderMarkdownContent(ctx, ctx.Result.Content)
737+ })
738+}
739+
740+func renderDownload(ctx *RenderContext) string {
741+ var params tools.DownloadParams
742+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
743+ return renderError(ctx, "Invalid download parameters")
744+ }
745+
746+ args := newParamBuilder().
747+ addMain(params.URL).
748+ addKeyValue("file", fsext.PrettyPath(params.FilePath)).
749+ addKeyValue("timeout", formatNonZero(params.Timeout)).
750+ build()
751+
752+ return renderWithParams(ctx, "Download", args, func() string {
753+ return renderPlainContent(ctx, ctx.Result.Content)
754+ })
755+}
756+
757+func renderGlob(ctx *RenderContext) string {
758+ var params tools.GlobParams
759+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
760+ return renderError(ctx, "Invalid glob parameters")
761+ }
762+
763+ args := newParamBuilder().
764+ addMain(params.Pattern).
765+ addKeyValue("path", params.Path).
766+ build()
767+
768+ return renderWithParams(ctx, "Glob", args, func() string {
769+ return renderPlainContent(ctx, ctx.Result.Content)
770+ })
771+}
772+
773+func renderGrep(ctx *RenderContext) string {
774+ var params tools.GrepParams
775+ var args []string
776+ if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
777+ args = newParamBuilder().
778+ addMain(params.Pattern).
779+ addKeyValue("path", params.Path).
780+ addKeyValue("include", params.Include).
781+ addFlag("literal", params.LiteralText).
782+ build()
783+ }
784+
785+ return renderWithParams(ctx, "Grep", args, func() string {
786+ return renderPlainContent(ctx, ctx.Result.Content)
787+ })
788+}
789+
790+func renderLS(ctx *RenderContext) string {
791+ var params tools.LSParams
792+ path := cmp.Or(params.Path, ".")
793+ args := newParamBuilder().addMain(path).build()
794+
795+ if err := unmarshalParams(ctx.Call.Input, &params); err == nil && params.Path != "" {
796+ args = newParamBuilder().addMain(params.Path).build()
797+ }
798+
799+ return renderWithParams(ctx, "List", args, func() string {
800+ return renderPlainContent(ctx, ctx.Result.Content)
801+ })
802+}
803+
804+func renderSourcegraph(ctx *RenderContext) string {
805+ var params tools.SourcegraphParams
806+ if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
807+ return renderError(ctx, "Invalid sourcegraph parameters")
808+ }
809+
810+ args := newParamBuilder().
811+ addMain(params.Query).
812+ addKeyValue("count", formatNonZero(params.Count)).
813+ addKeyValue("context", formatNonZero(params.ContextWindow)).
814+ build()
815+
816+ return renderWithParams(ctx, "Sourcegraph", args, func() string {
817+ return renderPlainContent(ctx, ctx.Result.Content)
818+ })
819+}
820+
821+func renderDiagnostics(ctx *RenderContext) string {
822+ args := newParamBuilder().addMain("project").build()
823+
824+ return renderWithParams(ctx, "Diagnostics", args, func() string {
825+ return renderPlainContent(ctx, ctx.Result.Content)
826+ })
827+}
828+
829+func renderAgent(ctx *RenderContext) string {
830+ s := ctx.Styles
831+ var params agent.AgentParams
832+ unmarshalParams(ctx.Call.Input, &params)
833+
834+ prompt := params.Prompt
835+ prompt = strings.ReplaceAll(prompt, "\n", " ")
836+
837+ header := makeHeader(ctx, "Agent", []string{})
838+ if res, done := earlyState(ctx, header); ctx.Cancelled && done {
839+ return res
840+ }
841+ taskTag := s.Tool.AgentTaskTag.Render("Task")
842+ remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
843+ remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
844+ prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
845+ header = lipgloss.JoinVertical(
846+ lipgloss.Left,
847+ header,
848+ "",
849+ lipgloss.JoinHorizontal(
850+ lipgloss.Left,
851+ taskTag,
852+ " ",
853+ prompt,
854+ ),
855+ )
856+ childTools := tree.Root(header)
857+
858+ // TODO: Render nested tool calls when available.
859+
860+ parts := []string{
861+ childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
862+ }
863+
864+ if ctx.Result.ToolCallID == "" {
865+ // Pending state - would show animation in TUI.
866+ parts = append(parts, "", s.Subtle.Render("Working..."))
867+ }
868+
869+ header = lipgloss.JoinVertical(
870+ lipgloss.Left,
871+ parts...,
872+ )
873+
874+ if ctx.Result.ToolCallID == "" {
875+ return header
876+ }
877+
878+ body := renderMarkdownContent(ctx, ctx.Result.Content)
879+ return joinHeaderBody(ctx, header, body)
880+}
881+
882+func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
883+ return func(children tree.Children, i int) string {
884+ if children.Length()-1 == i {
885+ return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
886+ }
887+ return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
888+ }
889+}