Detailed changes
@@ -0,0 +1,16 @@
+package common
+
+import (
+ "github.com/alecthomas/chroma/v2"
+ "github.com/charmbracelet/crush/internal/tui/exp/diffview"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// DiffFormatter returns a diff formatter with the given styles that can be
+// used to format diff outputs.
+func DiffFormatter(s *styles.Styles) *diffview.DiffView {
+ formatDiff := diffview.New()
+ style := chroma.MustNewStyle("crush", s.ChromaTheme())
+ diff := formatDiff.ChromaStyle(style).Style(s.Diff).TabWidth(4)
+ return diff
+}
@@ -0,0 +1,57 @@
+package common
+
+import (
+ "bytes"
+ "image/color"
+
+ "github.com/alecthomas/chroma/v2"
+ "github.com/alecthomas/chroma/v2/formatters"
+ "github.com/alecthomas/chroma/v2/lexers"
+ chromastyles "github.com/alecthomas/chroma/v2/styles"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// SyntaxHighlight applies syntax highlighting to the given source code based
+// on the file name and background color. It returns the highlighted code as a
+// string.
+func SyntaxHighlight(st *styles.Styles, source, fileName string, bg color.Color) (string, error) {
+ // Determine the language lexer to use
+ l := lexers.Match(fileName)
+ if l == nil {
+ l = lexers.Analyse(source)
+ }
+ if l == nil {
+ l = lexers.Fallback
+ }
+ l = chroma.Coalesce(l)
+
+ // Get the formatter
+ f := formatters.Get("terminal16m")
+ if f == nil {
+ f = formatters.Fallback
+ }
+
+ style := chroma.MustNewStyle("crush", st.ChromaTheme())
+
+ // Modify the style to use the provided background
+ s, err := style.Builder().Transform(
+ func(t chroma.StyleEntry) chroma.StyleEntry {
+ r, g, b, _ := bg.RGBA()
+ t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+ return t
+ },
+ ).Build()
+ if err != nil {
+ s = chromastyles.Fallback
+ }
+
+ // Tokenize and format
+ it, err := l.Tokenise(nil, source)
+ if err != nil {
+ return "", err
+ }
+
+ var buf bytes.Buffer
+ err = f.Format(&buf, s, it)
+ return buf.String(), err
+}
@@ -65,7 +65,7 @@ type ChatNoContentItem struct {
func NewChatNoContentItem(t *styles.Styles) *ChatNoContentItem {
c := new(ChatNoContentItem)
c.StringItem = list.NewStringItem("No message content").
- WithFocusStyles(&t.Chat.NoContentMessage, &t.Chat.NoContentMessage)
+ WithFocusStyles(&t.Chat.Message.NoContent, &t.Chat.Message.NoContent)
return c
}
@@ -88,7 +88,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
switch msg.Role {
case message.User:
item := list.NewMarkdownItem(msg.Content().String()).
- WithFocusStyles(&t.Chat.UserMessageFocused, &t.Chat.UserMessageBlurred)
+ WithFocusStyles(&t.Chat.Message.UserFocused, &t.Chat.Message.UserBlurred)
item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
// TODO: Add attachments
c.item = item
@@ -102,13 +102,13 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
reasoningThinking := strings.TrimSpace(reasoningContent.Thinking)
if finished && content == "" && finishedData.Reason == message.FinishReasonError {
- tag := t.Chat.ErrorTag.Render("ERROR")
- title := t.Chat.ErrorTitle.Render(finishedData.Message)
- details := t.Chat.ErrorDetails.Render(finishedData.Details)
+ tag := t.Chat.Message.ErrorTag.Render("ERROR")
+ title := t.Chat.Message.ErrorTitle.Render(finishedData.Message)
+ details := t.Chat.Message.ErrorDetails.Render(finishedData.Details)
errContent := fmt.Sprintf("%s %s\n\n%s", tag, title, details)
item := list.NewStringItem(errContent).
- WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+ WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
c.item = item
@@ -136,7 +136,7 @@ func NewChatMessageItem(t *styles.Styles, msg message.Message) *ChatMessageItem
}
item := list.NewMarkdownItem(strings.Join(parts, "\n")).
- WithFocusStyles(&t.Chat.AssistantMessageFocused, &t.Chat.AssistantMessageBlurred)
+ WithFocusStyles(&t.Chat.Message.AssistantFocused, &t.Chat.Message.AssistantBlurred)
item.SetHighlightStyle(list.LipglossStyleToCellStyler(t.TextSelection))
c.item = item
@@ -234,12 +234,10 @@ func (m *Chat) PrependItem(item list.Item) {
m.list.PrependItem(item)
}
-// AppendMessage appends a new message item to the chat list.
-func (m *Chat) AppendMessage(msg message.Message) {
- if msg.ID == "" {
- m.AppendItem(NewChatNoContentItem(m.com.Styles))
- } else {
- m.AppendItem(NewChatMessageItem(m.com.Styles, msg))
+// AppendMessages appends a new message item to the chat list.
+func (m *Chat) AppendMessages(msgs ...MessageItem) {
+ for _, msg := range msgs {
+ m.AppendItem(msg)
}
}
@@ -0,0 +1,753 @@
+package model
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "charm.land/lipgloss/v2"
+ uv "github.com/charmbracelet/ultraviolet"
+ "github.com/charmbracelet/x/ansi"
+
+ "github.com/charmbracelet/crush/internal/config"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/list"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/crush/internal/ui/toolrender"
+)
+
+// Identifiable is an interface for items that can provide a unique identifier.
+type Identifiable interface {
+ ID() string
+}
+
+// MessageItem represents a [message.Message] item that can be displayed in the
+// UI and be part of a [list.List] identifiable by a unique ID.
+type MessageItem interface {
+ list.Item
+ list.Focusable
+ list.Highlightable
+ Identifiable
+}
+
+// MessageContentItem represents rendered message content (text, markdown, errors, etc).
+type MessageContentItem struct {
+ list.BaseFocusable
+ list.BaseHighlightable
+ id string
+ content string
+ isMarkdown bool
+ maxWidth int
+ cache map[int]string // Cache for rendered content at different widths
+ sty *styles.Styles
+}
+
+// NewMessageContentItem creates a new message content item.
+func NewMessageContentItem(id, content string, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
+ m := &MessageContentItem{
+ id: id,
+ content: content,
+ isMarkdown: isMarkdown,
+ maxWidth: 120,
+ cache: make(map[int]string),
+ sty: sty,
+ }
+ m.InitHighlight()
+ return m
+}
+
+// ID implements Identifiable.
+func (m *MessageContentItem) ID() string {
+ return m.id
+}
+
+// Height implements list.Item.
+func (m *MessageContentItem) Height(width int) int {
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ if style := m.CurrentStyle(); style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ rendered := m.render(contentWidth)
+
+ // Apply focus/blur styling if configured to get accurate height
+ if style := m.CurrentStyle(); style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements list.Item.
+func (m *MessageContentItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ height := area.Dy()
+
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ style := m.CurrentStyle()
+ if style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ rendered := m.render(contentWidth)
+
+ // Apply focus/blur styling if configured
+ if style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ // Create temp buffer to draw content with highlighting
+ tempBuf := uv.NewScreenBuffer(width, height)
+
+ // Draw the rendered content to temp buffer
+ styled := uv.NewStyledString(rendered)
+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+ // Apply highlighting if active
+ m.ApplyHighlight(&tempBuf, width, height, style)
+
+ // Copy temp buffer to actual screen at the target area
+ tempBuf.Draw(scr, area)
+}
+
+// render renders the content at the given width, using cache if available.
+func (m *MessageContentItem) render(width int) string {
+ // Cap width to maxWidth for markdown
+ cappedWidth := width
+ if m.isMarkdown {
+ cappedWidth = min(width, m.maxWidth)
+ }
+
+ // Check cache first
+ if cached, ok := m.cache[cappedWidth]; ok {
+ return cached
+ }
+
+ // Not cached - render now
+ var rendered string
+ if m.isMarkdown {
+ renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+ result, err := renderer.Render(m.content)
+ if err != nil {
+ rendered = m.content
+ } else {
+ rendered = strings.TrimSuffix(result, "\n")
+ }
+ } else {
+ rendered = m.content
+ }
+
+ // Cache the result
+ m.cache[cappedWidth] = rendered
+ return rendered
+}
+
+// SetHighlight implements list.Highlightable and extends BaseHighlightable.
+func (m *MessageContentItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+ m.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+ // Clear cache when highlight changes
+ m.cache = make(map[int]string)
+}
+
+// ToolCallItem represents a rendered tool call with its header and content.
+type ToolCallItem struct {
+ list.BaseFocusable
+ list.BaseHighlightable
+ id string
+ toolCall message.ToolCall
+ toolResult message.ToolResult
+ cancelled bool
+ isNested bool
+ maxWidth int
+ cache map[int]cachedToolRender // Cache for rendered content at different widths
+ cacheKey string // Key to invalidate cache when content changes
+ sty *styles.Styles
+}
+
+// cachedToolRender stores both the rendered string and its height.
+type cachedToolRender struct {
+ content string
+ height int
+}
+
+// NewToolCallItem creates a new tool call item.
+func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem {
+ t := &ToolCallItem{
+ id: id,
+ toolCall: toolCall,
+ toolResult: toolResult,
+ cancelled: cancelled,
+ isNested: isNested,
+ maxWidth: 120,
+ cache: make(map[int]cachedToolRender),
+ cacheKey: generateCacheKey(toolCall, toolResult, cancelled),
+ sty: sty,
+ }
+ t.InitHighlight()
+ return t
+}
+
+// generateCacheKey creates a key that changes when tool call content changes.
+func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string {
+ // Simple key based on result state - when result arrives or changes, key changes
+ return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled)
+}
+
+// ID implements Identifiable.
+func (t *ToolCallItem) ID() string {
+ return t.id
+}
+
+// Height implements list.Item.
+func (t *ToolCallItem) Height(width int) int {
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ frameSize := 0
+ if style := t.CurrentStyle(); style != nil {
+ frameSize = style.GetHorizontalFrameSize()
+ contentWidth -= frameSize
+ }
+
+ cached := t.renderCached(contentWidth)
+
+ // Add frame size to height if needed
+ height := cached.height
+ if frameSize > 0 {
+ // Frame can add to height (borders, padding)
+ if style := t.CurrentStyle(); style != nil {
+ // Quick render to get accurate height with frame
+ rendered := style.Render(cached.content)
+ height = strings.Count(rendered, "\n") + 1
+ }
+ }
+
+ return height
+}
+
+// Draw implements list.Item.
+func (t *ToolCallItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ height := area.Dy()
+
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ style := t.CurrentStyle()
+ if style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ cached := t.renderCached(contentWidth)
+ rendered := cached.content
+
+ if style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ tempBuf := uv.NewScreenBuffer(width, height)
+ styled := uv.NewStyledString(rendered)
+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+ t.ApplyHighlight(&tempBuf, width, height, style)
+ tempBuf.Draw(scr, area)
+}
+
+// renderCached renders the tool call at the given width with caching.
+func (t *ToolCallItem) renderCached(width int) cachedToolRender {
+ cappedWidth := min(width, t.maxWidth)
+
+ // Check if we have a valid cache entry
+ if cached, ok := t.cache[cappedWidth]; ok {
+ return cached
+ }
+
+ // Render the tool call
+ ctx := &toolrender.RenderContext{
+ Call: t.toolCall,
+ Result: t.toolResult,
+ Cancelled: t.cancelled,
+ IsNested: t.isNested,
+ Width: cappedWidth,
+ Styles: t.sty,
+ }
+
+ rendered := toolrender.Render(ctx)
+ height := strings.Count(rendered, "\n") + 1
+
+ cached := cachedToolRender{
+ content: rendered,
+ height: height,
+ }
+ t.cache[cappedWidth] = cached
+ return cached
+}
+
+// SetHighlight implements list.Highlightable.
+func (t *ToolCallItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+ t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+ // Clear cache when highlight changes
+ t.cache = make(map[int]cachedToolRender)
+}
+
+// UpdateResult updates the tool result and invalidates the cache if needed.
+func (t *ToolCallItem) UpdateResult(result message.ToolResult) {
+ newKey := generateCacheKey(t.toolCall, result, t.cancelled)
+ if newKey != t.cacheKey {
+ t.toolResult = result
+ t.cacheKey = newKey
+ t.cache = make(map[int]cachedToolRender)
+ }
+}
+
+// AttachmentItem represents a file attachment in a user message.
+type AttachmentItem struct {
+ list.BaseFocusable
+ list.BaseHighlightable
+ id string
+ filename string
+ path string
+ sty *styles.Styles
+}
+
+// NewAttachmentItem creates a new attachment item.
+func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem {
+ a := &AttachmentItem{
+ id: id,
+ filename: filename,
+ path: path,
+ sty: sty,
+ }
+ a.InitHighlight()
+ return a
+}
+
+// ID implements Identifiable.
+func (a *AttachmentItem) ID() string {
+ return a.id
+}
+
+// Height implements list.Item.
+func (a *AttachmentItem) Height(width int) int {
+ return 1
+}
+
+// Draw implements list.Item.
+func (a *AttachmentItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ height := area.Dy()
+
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ style := a.CurrentStyle()
+ if style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ const maxFilenameWidth = 10
+ content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
+ " %s %s ",
+ styles.DocumentIcon,
+ ansi.Truncate(a.filename, maxFilenameWidth, "..."),
+ ))
+
+ if style != nil {
+ content = style.Render(content)
+ }
+
+ tempBuf := uv.NewScreenBuffer(width, height)
+ styled := uv.NewStyledString(content)
+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+ a.ApplyHighlight(&tempBuf, width, height, style)
+ tempBuf.Draw(scr, area)
+}
+
+// ThinkingItem represents thinking/reasoning content in assistant messages.
+type ThinkingItem struct {
+ list.BaseFocusable
+ list.BaseHighlightable
+ id string
+ thinking string
+ duration time.Duration
+ finished bool
+ maxWidth int
+ cache map[int]string
+ sty *styles.Styles
+}
+
+// NewThinkingItem creates a new thinking item.
+func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem {
+ t := &ThinkingItem{
+ id: id,
+ thinking: thinking,
+ duration: duration,
+ finished: finished,
+ maxWidth: 120,
+ cache: make(map[int]string),
+ sty: sty,
+ }
+ t.InitHighlight()
+ return t
+}
+
+// ID implements Identifiable.
+func (t *ThinkingItem) ID() string {
+ return t.id
+}
+
+// Height implements list.Item.
+func (t *ThinkingItem) Height(width int) int {
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ if style := t.CurrentStyle(); style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ rendered := t.render(contentWidth)
+ return strings.Count(rendered, "\n") + 1
+}
+
+// Draw implements list.Item.
+func (t *ThinkingItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ height := area.Dy()
+
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ style := t.CurrentStyle()
+ if style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ rendered := t.render(contentWidth)
+
+ if style != nil {
+ rendered = style.Render(rendered)
+ }
+
+ tempBuf := uv.NewScreenBuffer(width, height)
+ styled := uv.NewStyledString(rendered)
+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+ t.ApplyHighlight(&tempBuf, width, height, style)
+ tempBuf.Draw(scr, area)
+}
+
+// render renders the thinking content.
+func (t *ThinkingItem) render(width int) string {
+ cappedWidth := min(width, t.maxWidth)
+
+ if cached, ok := t.cache[cappedWidth]; ok {
+ return cached
+ }
+
+ renderer := common.PlainMarkdownRenderer(cappedWidth - 1)
+ rendered, err := renderer.Render(t.thinking)
+ if err != nil {
+ // Fallback to line-by-line rendering
+ lines := strings.Split(t.thinking, "\n")
+ var content strings.Builder
+ lineStyle := t.sty.PanelMuted
+ for i, line := range lines {
+ if line == "" {
+ continue
+ }
+ content.WriteString(lineStyle.Width(cappedWidth).Render(line))
+ if i < len(lines)-1 {
+ content.WriteString("\n")
+ }
+ }
+ rendered = content.String()
+ }
+
+ fullContent := strings.TrimSpace(rendered)
+
+ // Add footer if finished
+ if t.finished && t.duration > 0 {
+ footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String()))
+ fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer)
+ }
+
+ result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent)
+
+ t.cache[cappedWidth] = result
+ return result
+}
+
+// SetHighlight implements list.Highlightable.
+func (t *ThinkingItem) SetHighlight(startLine, startCol, endLine, endCol int) {
+ t.BaseHighlightable.SetHighlight(startLine, startCol, endLine, endCol)
+ t.cache = make(map[int]string)
+}
+
+// SectionHeaderItem represents a section header (e.g., assistant info).
+type SectionHeaderItem struct {
+ list.BaseFocusable
+ list.BaseHighlightable
+ id string
+ modelName string
+ duration time.Duration
+ isSectionHeader bool
+ sty *styles.Styles
+}
+
+// NewSectionHeaderItem creates a new section header item.
+func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem {
+ s := &SectionHeaderItem{
+ id: id,
+ modelName: modelName,
+ duration: duration,
+ isSectionHeader: true,
+ sty: sty,
+ }
+ s.InitHighlight()
+ return s
+}
+
+// ID implements Identifiable.
+func (s *SectionHeaderItem) ID() string {
+ return s.id
+}
+
+// IsSectionHeader returns true if this is a section header.
+func (s *SectionHeaderItem) IsSectionHeader() bool {
+ return s.isSectionHeader
+}
+
+// Height implements list.Item.
+func (s *SectionHeaderItem) Height(width int) int {
+ return 1
+}
+
+// Draw implements list.Item.
+func (s *SectionHeaderItem) Draw(scr uv.Screen, area uv.Rectangle) {
+ width := area.Dx()
+ height := area.Dy()
+
+ // Calculate content width accounting for frame size
+ contentWidth := width
+ style := s.CurrentStyle()
+ if style != nil {
+ contentWidth -= style.GetHorizontalFrameSize()
+ }
+
+ infoMsg := s.sty.Subtle.Render(s.duration.String())
+ icon := s.sty.Subtle.Render(styles.ModelIcon)
+ modelFormatted := s.sty.Muted.Render(s.modelName)
+ content := fmt.Sprintf("%s %s %s", icon, modelFormatted, infoMsg)
+
+ content = s.sty.Chat.Message.SectionHeader.Render(content)
+
+ if style != nil {
+ content = style.Render(content)
+ }
+
+ tempBuf := uv.NewScreenBuffer(width, height)
+ styled := uv.NewStyledString(content)
+ styled.Draw(&tempBuf, uv.Rect(0, 0, width, height))
+
+ s.ApplyHighlight(&tempBuf, width, height, style)
+ tempBuf.Draw(scr, area)
+}
+
+// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
+// all parts of the message as [MessageItem]s.
+//
+// For assistant messages with tool calls, pass a toolResults map to link results.
+// Use BuildToolResultMap to create this map from all messages in a session.
+func GetMessageItems(msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
+ sty := styles.DefaultStyles()
+ var items []MessageItem
+
+ // Skip tool result messages - they're displayed inline with tool calls
+ if msg.Role == message.Tool {
+ return items
+ }
+
+ // Create base styles for the message
+ var focusStyle, blurStyle lipgloss.Style
+ if msg.Role == message.User {
+ focusStyle = sty.Chat.Message.UserFocused
+ blurStyle = sty.Chat.Message.UserBlurred
+ } else {
+ focusStyle = sty.Chat.Message.AssistantFocused
+ blurStyle = sty.Chat.Message.AssistantBlurred
+ }
+
+ // Process user messages
+ if msg.Role == message.User {
+ // Add main text content
+ content := msg.Content().String()
+ if content != "" {
+ item := NewMessageContentItem(
+ fmt.Sprintf("%s-content", msg.ID),
+ content,
+ true, // User messages are markdown
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ }
+
+ // Add attachments
+ for i, attachment := range msg.BinaryContent() {
+ filename := filepath.Base(attachment.Path)
+ item := NewAttachmentItem(
+ fmt.Sprintf("%s-attachment-%d", msg.ID, i),
+ filename,
+ attachment.Path,
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ }
+
+ return items
+ }
+
+ // Process assistant messages
+ if msg.Role == message.Assistant {
+ // Check if we need to add a section header
+ finishData := msg.FinishPart()
+ if finishData != nil && msg.Model != "" {
+ model := config.Get().GetModel(msg.Provider, msg.Model)
+ modelName := "Unknown Model"
+ if model != nil {
+ modelName = model.Name
+ }
+
+ // Calculate duration (this would need the last user message time)
+ duration := time.Duration(0)
+ if finishData.Time > 0 {
+ duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second
+ }
+
+ header := NewSectionHeaderItem(
+ fmt.Sprintf("%s-header", msg.ID),
+ modelName,
+ duration,
+ &sty,
+ )
+ items = append(items, header)
+ }
+
+ // Add thinking content if present
+ reasoning := msg.ReasoningContent()
+ if strings.TrimSpace(reasoning.Thinking) != "" {
+ duration := time.Duration(0)
+ if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 {
+ duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second
+ }
+
+ item := NewThinkingItem(
+ fmt.Sprintf("%s-thinking", msg.ID),
+ reasoning.Thinking,
+ duration,
+ reasoning.FinishedAt > 0,
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ }
+
+ // Add main text content
+ content := msg.Content().String()
+ finished := msg.IsFinished()
+
+ // Handle special finish states
+ if finished && content == "" && finishData != nil {
+ switch finishData.Reason {
+ case message.FinishReasonEndTurn:
+ // No content to show
+ case message.FinishReasonCanceled:
+ item := NewMessageContentItem(
+ fmt.Sprintf("%s-content", msg.ID),
+ "*Canceled*",
+ true,
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ case message.FinishReasonError:
+ // Render error
+ errTag := sty.Chat.Message.ErrorTag.Render("ERROR")
+ truncated := ansi.Truncate(finishData.Message, 100, "...")
+ title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated))
+ details := sty.Chat.Message.ErrorDetails.Render(finishData.Details)
+ errorContent := fmt.Sprintf("%s\n\n%s", title, details)
+
+ item := NewMessageContentItem(
+ fmt.Sprintf("%s-error", msg.ID),
+ errorContent,
+ false,
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ }
+ } else if content != "" {
+ item := NewMessageContentItem(
+ fmt.Sprintf("%s-content", msg.ID),
+ content,
+ true, // Assistant messages are markdown
+ &sty,
+ )
+ item.SetFocusStyles(&focusStyle, &blurStyle)
+ items = append(items, item)
+ }
+
+ // Add tool calls
+ toolCalls := msg.ToolCalls()
+
+ // Use passed-in tool results map (if nil, use empty map)
+ resultMap := toolResults
+ if resultMap == nil {
+ resultMap = make(map[string]message.ToolResult)
+ }
+
+ for _, tc := range toolCalls {
+ result, hasResult := resultMap[tc.ID]
+ if !hasResult {
+ result = message.ToolResult{}
+ }
+
+ item := NewToolCallItem(
+ fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID),
+ tc,
+ result,
+ false, // cancelled state would need to be tracked separately
+ false, // nested state would be detected from tool results
+ &sty,
+ )
+
+ // Tool calls use muted style with optional focus border
+ item.SetFocusStyles(&sty.Chat.Message.ToolCallFocused, &sty.Chat.Message.ToolCallBlurred)
+
+ items = append(items, item)
+ }
+
+ return items
+ }
+
+ return items
+}
+
+// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
+// Tool result messages (role == message.Tool) contain the results that should be linked
+// to tool calls in assistant messages.
+func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult {
+ resultMap := make(map[string]message.ToolResult)
+ for _, msg := range messages {
+ if msg.Role == message.Tool {
+ for _, result := range msg.ToolResults() {
+ if result.ToolCallID != "" {
+ resultMap[result.ToolCallID] = result
+ }
+ }
+ }
+ }
+ return resultMap
+}
@@ -18,6 +18,7 @@ import (
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
"github.com/charmbracelet/crush/internal/history"
+ "github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/ui/common"
@@ -195,9 +196,21 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.session = &msg.sess
// Load the last 20 messages from this session.
msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
- for _, message := range msgs {
- m.chat.AppendMessage(message)
+
+ // Build tool result map to link tool calls with their results
+ msgPtrs := make([]*message.Message, len(msgs))
+ for i := range msgs {
+ msgPtrs[i] = &msgs[i]
+ }
+ toolResultMap := BuildToolResultMap(msgPtrs)
+
+ // Add messages to chat with linked tool results
+ items := make([]MessageItem, 0, len(msgs)*2)
+ for _, msg := range msgPtrs {
+ items = append(items, GetMessageItems(msg, toolResultMap)...)
}
+ m.chat.AppendMessages(items...)
+
// Notify that session loading is done to scroll to bottom. This is
// needed because we need to draw the chat list first before we can
// scroll to bottom.
@@ -9,6 +9,7 @@ import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "github.com/alecthomas/chroma/v2"
"github.com/charmbracelet/crush/internal/tui/exp/diffview"
"github.com/charmbracelet/glamour/v2/ansi"
"github.com/charmbracelet/x/exp/charmtone"
@@ -117,6 +118,7 @@ type Styles struct {
// Background
Background color.Color
+
// Logo
LogoFieldColor color.Color
LogoTitleColorA color.Color
@@ -124,6 +126,31 @@ type Styles struct {
LogoCharmColor color.Color
LogoVersionColor color.Color
+ // Colors - semantic colors for tool rendering.
+ Primary color.Color
+ Secondary color.Color
+ Tertiary color.Color
+ BgBase color.Color
+ BgBaseLighter color.Color
+ BgSubtle color.Color
+ BgOverlay color.Color
+ FgBase color.Color
+ FgMuted color.Color
+ FgHalfMuted color.Color
+ FgSubtle color.Color
+ Border color.Color
+ BorderColor color.Color // Border focus color
+ Warning color.Color
+ Info color.Color
+ White color.Color
+ BlueLight color.Color
+ Blue color.Color
+ Green color.Color
+ GreenDark color.Color
+ Red color.Color
+ RedDark color.Color
+ Yellow color.Color
+
// Section Title
Section struct {
Title lipgloss.Style
@@ -154,16 +181,120 @@ type Styles struct {
// Chat
Chat struct {
- UserMessageBlurred lipgloss.Style
- UserMessageFocused lipgloss.Style
- AssistantMessageBlurred lipgloss.Style
- AssistantMessageFocused lipgloss.Style
- NoContentMessage lipgloss.Style
- ThinkingMessage lipgloss.Style
-
- ErrorTag lipgloss.Style
- ErrorTitle lipgloss.Style
- ErrorDetails lipgloss.Style
+ // Message item styles
+ Message struct {
+ UserBlurred lipgloss.Style
+ UserFocused lipgloss.Style
+ AssistantBlurred lipgloss.Style
+ AssistantFocused lipgloss.Style
+ NoContent lipgloss.Style
+ Thinking lipgloss.Style
+ ErrorTag lipgloss.Style
+ ErrorTitle lipgloss.Style
+ ErrorDetails lipgloss.Style
+ Attachment lipgloss.Style
+ ToolCallFocused lipgloss.Style
+ ToolCallBlurred lipgloss.Style
+ ThinkingFooter lipgloss.Style
+ SectionHeader lipgloss.Style
+ }
+ }
+
+ // Tool - styles for tool call rendering
+ Tool struct {
+ // Icon styles with tool status
+ IconPending lipgloss.Style // Pending operation icon
+ IconSuccess lipgloss.Style // Successful operation icon
+ IconError lipgloss.Style // Error operation icon
+ IconCancelled lipgloss.Style // Cancelled operation icon
+
+ // Tool name styles
+ NameNormal lipgloss.Style // Normal tool name
+ NameNested lipgloss.Style // Nested tool name
+
+ // Parameter list styles
+ ParamMain lipgloss.Style // Main parameter
+ ParamKey lipgloss.Style // Parameter keys
+
+ // Content rendering styles
+ ContentLine lipgloss.Style // Individual content line with background and width
+ ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
+ ContentCodeLine lipgloss.Style // Code line with background and width
+ ContentCodeBg color.Color // Background color for syntax highlighting
+ BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2))
+
+ // Deprecated - kept for backward compatibility
+ ContentBg lipgloss.Style // Content background
+ ContentText lipgloss.Style // Content text
+ ContentLineNumber lipgloss.Style // Line numbers in code
+
+ // State message styles
+ StateWaiting lipgloss.Style // "Waiting for tool response..."
+ StateCancelled lipgloss.Style // "Canceled."
+
+ // Error styles
+ ErrorTag lipgloss.Style // ERROR tag
+ ErrorMessage lipgloss.Style // Error message text
+
+ // Diff styles
+ DiffTruncation lipgloss.Style // Diff truncation message with padding
+
+ // Multi-edit note styles
+ NoteTag lipgloss.Style // NOTE tag (yellow background)
+ NoteMessage lipgloss.Style // Note message text
+
+ // Job header styles (for bash jobs)
+ JobIconPending lipgloss.Style // Pending job icon (green dark)
+ JobIconError lipgloss.Style // Error job icon (red dark)
+ JobIconSuccess lipgloss.Style // Success job icon (green)
+ JobToolName lipgloss.Style // Job tool name "Bash" (blue)
+ JobAction lipgloss.Style // Action text (Start, Output, Kill)
+ JobPID lipgloss.Style // PID text
+ JobDescription lipgloss.Style // Description text
+
+ // Agent task styles
+ AgentTaskTag lipgloss.Style // Agent task tag (blue background, bold)
+ AgentPrompt lipgloss.Style // Agent prompt text
+ }
+}
+
+// ChromaTheme converts the current markdown chroma styles to a chroma
+// StyleEntries map.
+func (s *Styles) ChromaTheme() chroma.StyleEntries {
+ rules := s.Markdown.CodeBlock
+
+ return chroma.StyleEntries{
+ chroma.Text: chromaStyle(rules.Chroma.Text),
+ chroma.Error: chromaStyle(rules.Chroma.Error),
+ chroma.Comment: chromaStyle(rules.Chroma.Comment),
+ chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
+ chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
+ chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
+ chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
+ chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
+ chroma.Operator: chromaStyle(rules.Chroma.Operator),
+ chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
+ chroma.Name: chromaStyle(rules.Chroma.Name),
+ chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
+ chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
+ chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
+ chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
+ chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
+ chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
+ chroma.NameException: chromaStyle(rules.Chroma.NameException),
+ chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
+ chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
+ chroma.Literal: chromaStyle(rules.Chroma.Literal),
+ chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
+ chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
+ chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
+ chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+ chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
+ chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
+ chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
+ chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
+ chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
+ chroma.Background: chromaStyle(rules.Chroma.Background),
}
}
@@ -202,6 +333,7 @@ func DefaultStyles() Styles {
blue = charmtone.Malibu
// yellow = charmtone.Mustard
+ yellow = charmtone.Mustard
// citron = charmtone.Citron
green = charmtone.Julep
@@ -222,6 +354,31 @@ func DefaultStyles() Styles {
s.Background = bgBase
+ // Populate color fields
+ s.Primary = primary
+ s.Secondary = secondary
+ s.Tertiary = tertiary
+ s.BgBase = bgBase
+ s.BgBaseLighter = bgBaseLighter
+ s.BgSubtle = bgSubtle
+ s.BgOverlay = bgOverlay
+ s.FgBase = fgBase
+ s.FgMuted = fgMuted
+ s.FgHalfMuted = fgHalfMuted
+ s.FgSubtle = fgSubtle
+ s.Border = border
+ s.BorderColor = borderFocus
+ s.Warning = warning
+ s.Info = info
+ s.White = white
+ s.BlueLight = blueLight
+ s.Blue = blue
+ s.Green = green
+ s.GreenDark = greenDark
+ s.Red = red
+ s.RedDark = redDark
+ s.Yellow = yellow
+
s.TextInput = textinput.Styles{
Focused: textinput.StyleState{
Text: base,
@@ -580,6 +737,54 @@ func DefaultStyles() Styles {
s.ToolCallCancelled = s.Muted.SetString(ToolPending)
s.EarlyStateMessage = s.Subtle.PaddingLeft(2)
+ // Tool rendering styles
+ s.Tool.IconPending = base.Foreground(greenDark).SetString(ToolPending)
+ s.Tool.IconSuccess = base.Foreground(green).SetString(ToolSuccess)
+ s.Tool.IconError = base.Foreground(redDark).SetString(ToolError)
+ s.Tool.IconCancelled = s.Muted.SetString(ToolPending)
+
+ s.Tool.NameNormal = base.Foreground(blue)
+ s.Tool.NameNested = base.Foreground(fgHalfMuted)
+
+ s.Tool.ParamMain = s.Subtle
+ s.Tool.ParamKey = s.Subtle
+
+ // Content rendering - prepared styles that accept width parameter
+ s.Tool.ContentLine = s.Muted.Background(bgBaseLighter)
+ s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
+ s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
+ s.Tool.ContentCodeBg = bgBase
+ s.Tool.BodyPadding = base.PaddingLeft(2)
+
+ // Deprecated - kept for backward compatibility
+ s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
+ s.Tool.ContentText = s.Muted
+ s.Tool.ContentLineNumber = s.Subtle
+
+ s.Tool.StateWaiting = base.Foreground(fgSubtle)
+ s.Tool.StateCancelled = base.Foreground(fgSubtle)
+
+ s.Tool.ErrorTag = base.Padding(0, 1).Background(red).Foreground(white)
+ s.Tool.ErrorMessage = base.Foreground(fgHalfMuted)
+
+ // Diff and multi-edit styles
+ s.Tool.DiffTruncation = s.Muted.Background(bgBaseLighter).PaddingLeft(2)
+ s.Tool.NoteTag = base.Padding(0, 1).Background(yellow).Foreground(white)
+ s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
+
+ // Job header styles
+ s.Tool.JobIconPending = base.Foreground(greenDark)
+ s.Tool.JobIconError = base.Foreground(redDark)
+ s.Tool.JobIconSuccess = base.Foreground(green)
+ s.Tool.JobToolName = base.Foreground(blue)
+ s.Tool.JobAction = base.Foreground(fgHalfMuted)
+ s.Tool.JobPID = s.Subtle
+ s.Tool.JobDescription = s.Subtle
+
+ // Agent task styles
+ s.Tool.AgentTaskTag = base.Bold(true).Padding(0, 1).MarginLeft(2).Background(blueLight).Foreground(white)
+ s.Tool.AgentPrompt = s.Muted
+
// Buttons
s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary)
s.ButtonBlur = s.Base.Background(bgSubtle)
@@ -633,19 +838,29 @@ func DefaultStyles() Styles {
Left: "▌",
}
- s.Chat.NoContentMessage = lipgloss.NewStyle().Foreground(fgBase)
- s.Chat.UserMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+ s.Chat.Message.NoContent = lipgloss.NewStyle().Foreground(fgBase)
+ s.Chat.Message.UserBlurred = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
BorderForeground(primary).BorderStyle(normalBorder)
- s.Chat.UserMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+ s.Chat.Message.UserFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
BorderForeground(primary).BorderStyle(messageFocussedBorder)
- s.Chat.AssistantMessageBlurred = s.Chat.NoContentMessage.PaddingLeft(2)
- s.Chat.AssistantMessageFocused = s.Chat.NoContentMessage.PaddingLeft(1).BorderLeft(true).
+ s.Chat.Message.AssistantBlurred = s.Chat.Message.NoContent.PaddingLeft(2)
+ s.Chat.Message.AssistantFocused = s.Chat.Message.NoContent.PaddingLeft(1).BorderLeft(true).
BorderForeground(greenDark).BorderStyle(messageFocussedBorder)
- s.Chat.ThinkingMessage = lipgloss.NewStyle().MaxHeight(10)
- s.Chat.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
+ s.Chat.Message.Thinking = lipgloss.NewStyle().MaxHeight(10)
+ s.Chat.Message.ErrorTag = lipgloss.NewStyle().Padding(0, 1).
Background(red).Foreground(white)
- s.Chat.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
- s.Chat.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+ s.Chat.Message.ErrorTitle = lipgloss.NewStyle().Foreground(fgHalfMuted)
+ s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
+
+ // Message item styles
+ s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
+ s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
+ BorderStyle(messageFocussedBorder).
+ BorderLeft(true).
+ BorderForeground(greenDark)
+ s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
+ s.Chat.Message.ThinkingFooter = s.Base
+ s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
// Text selection.
s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
@@ -657,3 +872,36 @@ func DefaultStyles() Styles {
func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func uintPtr(u uint) *uint { return &u }
+func chromaStyle(style ansi.StylePrimitive) string {
+ var s string
+
+ if style.Color != nil {
+ s = *style.Color
+ }
+ if style.BackgroundColor != nil {
+ if s != "" {
+ s += " "
+ }
+ s += "bg:" + *style.BackgroundColor
+ }
+ if style.Italic != nil && *style.Italic {
+ if s != "" {
+ s += " "
+ }
+ s += "italic"
+ }
+ if style.Bold != nil && *style.Bold {
+ if s != "" {
+ s += " "
+ }
+ s += "bold"
+ }
+ if style.Underline != nil && *style.Underline {
+ if s != "" {
+ s += " "
+ }
+ s += "underline"
+ }
+
+ return s
+}
@@ -0,0 +1,889 @@
+package toolrender
+
+import (
+ "cmp"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "charm.land/lipgloss/v2/tree"
+ "github.com/charmbracelet/crush/internal/agent"
+ "github.com/charmbracelet/crush/internal/agent/tools"
+ "github.com/charmbracelet/crush/internal/ansiext"
+ "github.com/charmbracelet/crush/internal/fsext"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// RenderContext provides the context needed for rendering a tool call.
+type RenderContext struct {
+ Call message.ToolCall
+ Result message.ToolResult
+ Cancelled bool
+ IsNested bool
+ Width int
+ Styles *styles.Styles
+}
+
+// TextWidth returns the available width for content accounting for borders.
+func (rc *RenderContext) TextWidth() int {
+ if rc.IsNested {
+ return rc.Width - 6
+ }
+ return rc.Width - 5
+}
+
+// Fit truncates content to fit within the specified width with ellipsis.
+func (rc *RenderContext) Fit(content string, width int) string {
+ lineStyle := rc.Styles.Muted
+ dots := lineStyle.Render("…")
+ return ansi.Truncate(content, width, dots)
+}
+
+// Render renders a tool call using the appropriate renderer based on tool name.
+func Render(ctx *RenderContext) string {
+ switch ctx.Call.Name {
+ case tools.ViewToolName:
+ return renderView(ctx)
+ case tools.EditToolName:
+ return renderEdit(ctx)
+ case tools.MultiEditToolName:
+ return renderMultiEdit(ctx)
+ case tools.WriteToolName:
+ return renderWrite(ctx)
+ case tools.BashToolName:
+ return renderBash(ctx)
+ case tools.JobOutputToolName:
+ return renderJobOutput(ctx)
+ case tools.JobKillToolName:
+ return renderJobKill(ctx)
+ case tools.FetchToolName:
+ return renderSimpleFetch(ctx)
+ case tools.AgenticFetchToolName:
+ return renderAgenticFetch(ctx)
+ case tools.WebFetchToolName:
+ return renderWebFetch(ctx)
+ case tools.DownloadToolName:
+ return renderDownload(ctx)
+ case tools.GlobToolName:
+ return renderGlob(ctx)
+ case tools.GrepToolName:
+ return renderGrep(ctx)
+ case tools.LSToolName:
+ return renderLS(ctx)
+ case tools.SourcegraphToolName:
+ return renderSourcegraph(ctx)
+ case tools.DiagnosticsToolName:
+ return renderDiagnostics(ctx)
+ case agent.AgentToolName:
+ return renderAgent(ctx)
+ default:
+ return renderGeneric(ctx)
+ }
+}
+
+// Helper functions
+
+func unmarshalParams(input string, target any) error {
+ return json.Unmarshal([]byte(input), target)
+}
+
+type paramBuilder struct {
+ args []string
+}
+
+func newParamBuilder() *paramBuilder {
+ return ¶mBuilder{args: make([]string, 0)}
+}
+
+func (pb *paramBuilder) addMain(value string) *paramBuilder {
+ if value != "" {
+ pb.args = append(pb.args, value)
+ }
+ return pb
+}
+
+func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
+ if value != "" {
+ pb.args = append(pb.args, key, value)
+ }
+ return pb
+}
+
+func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
+ if value {
+ pb.args = append(pb.args, key, "true")
+ }
+ return pb
+}
+
+func (pb *paramBuilder) build() []string {
+ return pb.args
+}
+
+func formatNonZero[T comparable](value T) string {
+ var zero T
+ if value == zero {
+ return ""
+ }
+ return fmt.Sprintf("%v", value)
+}
+
+func makeHeader(ctx *RenderContext, toolName string, args []string) string {
+ if ctx.IsNested {
+ return makeNestedHeader(ctx, toolName, args)
+ }
+ s := ctx.Styles
+ var icon string
+ if ctx.Result.ToolCallID != "" {
+ if ctx.Result.IsError {
+ icon = s.Tool.IconError.Render()
+ } else {
+ icon = s.Tool.IconSuccess.Render()
+ }
+ } else if ctx.Cancelled {
+ icon = s.Tool.IconCancelled.Render()
+ } else {
+ icon = s.Tool.IconPending.Render()
+ }
+ tool := s.Tool.NameNormal.Render(toolName)
+ prefix := fmt.Sprintf("%s %s ", icon, tool)
+ return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
+}
+
+func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
+ s := ctx.Styles
+ var icon string
+ if ctx.Result.ToolCallID != "" {
+ if ctx.Result.IsError {
+ icon = s.Tool.IconError.Render()
+ } else {
+ icon = s.Tool.IconSuccess.Render()
+ }
+ } else if ctx.Cancelled {
+ icon = s.Tool.IconCancelled.Render()
+ } else {
+ icon = s.Tool.IconPending.Render()
+ }
+ tool := s.Tool.NameNested.Render(toolName)
+ prefix := fmt.Sprintf("%s %s ", icon, tool)
+ return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
+}
+
+func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
+ s := ctx.Styles
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
+ mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
+ }
+
+ if len(params) == 1 {
+ return s.Tool.ParamMain.Render(mainParam)
+ }
+ otherParams := params[1:]
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
+ if remainingWidth < 30 {
+ return s.Tool.ParamMain.Render(mainParam)
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
+}
+
+func earlyState(ctx *RenderContext, header string) (string, bool) {
+ s := ctx.Styles
+ message := ""
+ switch {
+ case ctx.Result.IsError:
+ message = renderToolError(ctx)
+ case ctx.Cancelled:
+ message = s.Tool.StateCancelled.Render("Canceled.")
+ case ctx.Result.ToolCallID == "":
+ message = s.Tool.StateWaiting.Render("Waiting for tool response...")
+ default:
+ return "", false
+ }
+
+ message = s.Tool.BodyPadding.Render(message)
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
+}
+
+func renderToolError(ctx *RenderContext) string {
+ s := ctx.Styles
+ errTag := s.Tool.ErrorTag.Render("ERROR")
+ msg := ctx.Result.Content
+ if msg == "" {
+ msg = "An error occurred"
+ }
+ truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
+ return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
+}
+
+func joinHeaderBody(ctx *RenderContext, header, body string) string {
+ s := ctx.Styles
+ if body == "" {
+ return header
+ }
+ body = s.Tool.BodyPadding.Render(body)
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
+}
+
+func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
+ header := makeHeader(ctx, toolName, args)
+ if ctx.IsNested {
+ return header
+ }
+ if res, done := earlyState(ctx, header); done {
+ return res
+ }
+ body := contentRenderer()
+ return joinHeaderBody(ctx, header, body)
+}
+
+func renderError(ctx *RenderContext, message string) string {
+ s := ctx.Styles
+ header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
+ errorTag := s.Tool.ErrorTag.Render("ERROR")
+ message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
+ return joinHeaderBody(ctx, header, errorTag+" "+message)
+}
+
+func renderPlainContent(ctx *RenderContext, content string) string {
+ s := ctx.Styles
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+ lines := strings.Split(content, "\n")
+
+ width := ctx.TextWidth() - 2
+ var out []string
+ for i, ln := range lines {
+ if i >= responseContextHeight {
+ break
+ }
+ ln = ansiext.Escape(ln)
+ ln = " " + ln
+ if len(ln) > width {
+ ln = ctx.Fit(ln, width)
+ }
+ out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
+ }
+
+ if len(lines) > responseContextHeight {
+ out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
+ }
+
+ return strings.Join(out, "\n")
+}
+
+func renderMarkdownContent(ctx *RenderContext, content string) string {
+ s := ctx.Styles
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+
+ width := ctx.TextWidth() - 2
+ width = min(width, 120)
+
+ renderer := common.PlainMarkdownRenderer(width)
+ rendered, err := renderer.Render(content)
+ if err != nil {
+ return renderPlainContent(ctx, content)
+ }
+
+ lines := strings.Split(rendered, "\n")
+
+ var out []string
+ for i, ln := range lines {
+ if i >= responseContextHeight {
+ break
+ }
+ out = append(out, ln)
+ }
+
+ style := s.Tool.ContentLine
+ if len(lines) > responseContextHeight {
+ out = append(out, s.Tool.ContentTruncation.
+ Width(width-2).
+ Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
+ }
+
+ return style.Render(strings.Join(out, "\n"))
+}
+
+func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
+ s := ctx.Styles
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ truncated := truncateHeight(content, responseContextHeight)
+
+ lines := strings.Split(truncated, "\n")
+ for i, ln := range lines {
+ lines[i] = ansiext.Escape(ln)
+ }
+
+ bg := s.Tool.ContentCodeBg
+ highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
+ lines = strings.Split(highlighted, "\n")
+
+ width := ctx.TextWidth() - 2
+ gutterWidth := getDigits(offset+len(lines)) + 1
+
+ var out []string
+ for i, ln := range lines {
+ lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
+ gutter := s.Subtle.Render(lineNum + " ")
+ ln = " " + ln
+ if lipgloss.Width(gutter+ln) > width {
+ ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
+ }
+ out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
+ }
+
+ contentLines := strings.Split(content, "\n")
+ if len(contentLines) > responseContextHeight {
+ out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
+ }
+
+ return strings.Join(out, "\n")
+}
+
+func getDigits(n int) int {
+ if n == 0 {
+ return 1
+ }
+ if n < 0 {
+ n = -n
+ }
+
+ digits := 0
+ for n > 0 {
+ n /= 10
+ digits++
+ }
+
+ return digits
+}
+
+func truncateHeight(content string, maxLines int) string {
+ lines := strings.Split(content, "\n")
+ if len(lines) <= maxLines {
+ return content
+ }
+ return strings.Join(lines[:maxLines], "\n")
+}
+
+func prettifyToolName(name string) string {
+ switch name {
+ case "agent":
+ return "Agent"
+ case "bash":
+ return "Bash"
+ case "job_output":
+ return "Job: Output"
+ case "job_kill":
+ return "Job: Kill"
+ case "download":
+ return "Download"
+ case "edit":
+ return "Edit"
+ case "multiedit":
+ return "Multi-Edit"
+ case "fetch":
+ return "Fetch"
+ case "agentic_fetch":
+ return "Agentic Fetch"
+ case "web_fetch":
+ return "Fetching"
+ case "glob":
+ return "Glob"
+ case "grep":
+ return "Grep"
+ case "ls":
+ return "List"
+ case "sourcegraph":
+ return "Sourcegraph"
+ case "view":
+ return "View"
+ case "write":
+ return "Write"
+ case "lsp_references":
+ return "Find References"
+ case "lsp_diagnostics":
+ return "Diagnostics"
+ default:
+ parts := strings.Split(name, "_")
+ for i := range parts {
+ if len(parts[i]) > 0 {
+ parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
+ }
+ }
+ return strings.Join(parts, " ")
+ }
+}
+
+// Tool-specific renderers
+
+func renderGeneric(ctx *RenderContext) string {
+ return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderView(ctx *RenderContext) string {
+ var params tools.ViewParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid view parameters")
+ }
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := newParamBuilder().
+ addMain(file).
+ addKeyValue("limit", formatNonZero(params.Limit)).
+ addKeyValue("offset", formatNonZero(params.Offset)).
+ build()
+
+ return renderWithParams(ctx, "View", args, func() string {
+ var meta tools.ViewResponseMetadata
+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ }
+ return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
+ })
+}
+
+func renderEdit(ctx *RenderContext) string {
+ s := ctx.Styles
+ var params tools.EditParams
+ var args []string
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
+ file := fsext.PrettyPath(params.FilePath)
+ args = newParamBuilder().addMain(file).build()
+ }
+
+ return renderWithParams(ctx, "Edit", args, func() string {
+ var meta tools.EditResponseMetadata
+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ }
+
+ formatter := common.DiffFormatter(ctx.Styles).
+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
+ Width(ctx.TextWidth() - 2)
+ if ctx.TextWidth() > 120 {
+ formatter = formatter.Split()
+ }
+ formatted := formatter.String()
+ if lipgloss.Height(formatted) > responseContextHeight {
+ contentLines := strings.Split(formatted, "\n")
+ truncateMessage := s.Tool.DiffTruncation.
+ Width(ctx.TextWidth() - 2).
+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
+ }
+ return formatted
+ })
+}
+
+func renderMultiEdit(ctx *RenderContext) string {
+ s := ctx.Styles
+ var params tools.MultiEditParams
+ var args []string
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
+ file := fsext.PrettyPath(params.FilePath)
+ args = newParamBuilder().
+ addMain(file).
+ addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
+ build()
+ }
+
+ return renderWithParams(ctx, "Multi-Edit", args, func() string {
+ var meta tools.MultiEditResponseMetadata
+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ }
+
+ formatter := common.DiffFormatter(ctx.Styles).
+ Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
+ After(fsext.PrettyPath(params.FilePath), meta.NewContent).
+ Width(ctx.TextWidth() - 2)
+ if ctx.TextWidth() > 120 {
+ formatter = formatter.Split()
+ }
+ formatted := formatter.String()
+ if lipgloss.Height(formatted) > responseContextHeight {
+ contentLines := strings.Split(formatted, "\n")
+ truncateMessage := s.Tool.DiffTruncation.
+ Width(ctx.TextWidth() - 2).
+ Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
+ formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
+ }
+
+ // Add note about failed edits if any.
+ if len(meta.EditsFailed) > 0 {
+ noteTag := s.Tool.NoteTag.Render("NOTE")
+ noteMsg := s.Tool.NoteMessage.Render(
+ fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
+ formatted = formatted + "\n\n" + noteTag + " " + noteMsg
+ }
+
+ return formatted
+ })
+}
+
+func renderWrite(ctx *RenderContext) string {
+ var params tools.WriteParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid write parameters")
+ }
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := newParamBuilder().addMain(file).build()
+
+ return renderWithParams(ctx, "Write", args, func() string {
+ return renderCodeContent(ctx, params.FilePath, params.Content, 0)
+ })
+}
+
+func renderBash(ctx *RenderContext) string {
+ var params tools.BashParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid bash parameters")
+ }
+
+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
+ cmd = strings.ReplaceAll(cmd, "\t", " ")
+ args := newParamBuilder().
+ addMain(cmd).
+ addFlag("background", params.RunInBackground).
+ build()
+
+ if ctx.Call.Finished {
+ var meta tools.BashResponseMetadata
+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
+ if meta.Background {
+ description := cmp.Or(meta.Description, params.Command)
+ width := ctx.TextWidth()
+ if ctx.IsNested {
+ width -= 4
+ }
+ header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
+ if ctx.IsNested {
+ return header
+ }
+ if res, done := earlyState(ctx, header); done {
+ return res
+ }
+ content := "Command: " + params.Command + "\n" + ctx.Result.Content
+ body := renderPlainContent(ctx, content)
+ return joinHeaderBody(ctx, header, body)
+ }
+ }
+
+ return renderWithParams(ctx, "Bash", args, func() string {
+ var meta tools.BashResponseMetadata
+ if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ }
+ if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
+ meta.Output = ctx.Result.Content
+ }
+
+ if meta.Output == "" {
+ return ""
+ }
+ return renderPlainContent(ctx, meta.Output)
+ })
+}
+
+func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
+ s := ctx.Styles
+ icon := s.Tool.JobIconPending.Render(styles.ToolPending)
+ if ctx.Result.ToolCallID != "" {
+ if ctx.Result.IsError {
+ icon = s.Tool.JobIconError.Render(styles.ToolError)
+ } else {
+ icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
+ }
+ } else if ctx.Cancelled {
+ icon = s.Muted.Render(styles.ToolPending)
+ }
+
+ toolName := s.Tool.JobToolName.Render("Bash")
+ actionPart := s.Tool.JobAction.Render(action)
+ pidPart := s.Tool.JobPID.Render(pid)
+
+ prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
+ remainingWidth := width - lipgloss.Width(prefix)
+
+ descDisplay := ansi.Truncate(description, remainingWidth, "…")
+ descDisplay = s.Tool.JobDescription.Render(descDisplay)
+
+ return prefix + descDisplay
+}
+
+func renderJobOutput(ctx *RenderContext) string {
+ var params tools.JobOutputParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid job output parameters")
+ }
+
+ width := ctx.TextWidth()
+ if ctx.IsNested {
+ width -= 4
+ }
+
+ var meta tools.JobOutputResponseMetadata
+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
+ description := cmp.Or(meta.Description, meta.Command)
+
+ header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
+ if ctx.IsNested {
+ return header
+ }
+ if res, done := earlyState(ctx, header); done {
+ return res
+ }
+ body := renderPlainContent(ctx, ctx.Result.Content)
+ return joinHeaderBody(ctx, header, body)
+}
+
+func renderJobKill(ctx *RenderContext) string {
+ var params tools.JobKillParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid job kill parameters")
+ }
+
+ width := ctx.TextWidth()
+ if ctx.IsNested {
+ width -= 4
+ }
+
+ var meta tools.JobKillResponseMetadata
+ _ = unmarshalParams(ctx.Result.Metadata, &meta)
+ description := cmp.Or(meta.Description, meta.Command)
+
+ header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
+ if ctx.IsNested {
+ return header
+ }
+ if res, done := earlyState(ctx, header); done {
+ return res
+ }
+ body := renderPlainContent(ctx, ctx.Result.Content)
+ return joinHeaderBody(ctx, header, body)
+}
+
+func renderSimpleFetch(ctx *RenderContext) string {
+ var params tools.FetchParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid fetch parameters")
+ }
+
+ args := newParamBuilder().
+ addMain(params.URL).
+ addKeyValue("format", params.Format).
+ addKeyValue("timeout", formatNonZero(params.Timeout)).
+ build()
+
+ return renderWithParams(ctx, "Fetch", args, func() string {
+ path := "file." + params.Format
+ return renderCodeContent(ctx, path, ctx.Result.Content, 0)
+ })
+}
+
+func renderAgenticFetch(ctx *RenderContext) string {
+ // TODO: Implement nested tool call rendering with tree.
+ return renderGeneric(ctx)
+}
+
+func renderWebFetch(ctx *RenderContext) string {
+ var params tools.WebFetchParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid web fetch parameters")
+ }
+
+ args := newParamBuilder().addMain(params.URL).build()
+
+ return renderWithParams(ctx, "Fetching", args, func() string {
+ return renderMarkdownContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderDownload(ctx *RenderContext) string {
+ var params tools.DownloadParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid download parameters")
+ }
+
+ args := newParamBuilder().
+ addMain(params.URL).
+ addKeyValue("file", fsext.PrettyPath(params.FilePath)).
+ addKeyValue("timeout", formatNonZero(params.Timeout)).
+ build()
+
+ return renderWithParams(ctx, "Download", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderGlob(ctx *RenderContext) string {
+ var params tools.GlobParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid glob parameters")
+ }
+
+ args := newParamBuilder().
+ addMain(params.Pattern).
+ addKeyValue("path", params.Path).
+ build()
+
+ return renderWithParams(ctx, "Glob", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderGrep(ctx *RenderContext) string {
+ var params tools.GrepParams
+ var args []string
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
+ args = newParamBuilder().
+ addMain(params.Pattern).
+ addKeyValue("path", params.Path).
+ addKeyValue("include", params.Include).
+ addFlag("literal", params.LiteralText).
+ build()
+ }
+
+ return renderWithParams(ctx, "Grep", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderLS(ctx *RenderContext) string {
+ var params tools.LSParams
+ path := cmp.Or(params.Path, ".")
+ args := newParamBuilder().addMain(path).build()
+
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil && params.Path != "" {
+ args = newParamBuilder().addMain(params.Path).build()
+ }
+
+ return renderWithParams(ctx, "List", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderSourcegraph(ctx *RenderContext) string {
+ var params tools.SourcegraphParams
+ if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
+ return renderError(ctx, "Invalid sourcegraph parameters")
+ }
+
+ args := newParamBuilder().
+ addMain(params.Query).
+ addKeyValue("count", formatNonZero(params.Count)).
+ addKeyValue("context", formatNonZero(params.ContextWindow)).
+ build()
+
+ return renderWithParams(ctx, "Sourcegraph", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderDiagnostics(ctx *RenderContext) string {
+ args := newParamBuilder().addMain("project").build()
+
+ return renderWithParams(ctx, "Diagnostics", args, func() string {
+ return renderPlainContent(ctx, ctx.Result.Content)
+ })
+}
+
+func renderAgent(ctx *RenderContext) string {
+ s := ctx.Styles
+ var params agent.AgentParams
+ unmarshalParams(ctx.Call.Input, ¶ms)
+
+ prompt := params.Prompt
+ prompt = strings.ReplaceAll(prompt, "\n", " ")
+
+ header := makeHeader(ctx, "Agent", []string{})
+ if res, done := earlyState(ctx, header); ctx.Cancelled && done {
+ return res
+ }
+ taskTag := s.Tool.AgentTaskTag.Render("Task")
+ remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
+ remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
+ prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+ header = lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ taskTag,
+ " ",
+ prompt,
+ ),
+ )
+ childTools := tree.Root(header)
+
+ // TODO: Render nested tool calls when available.
+
+ parts := []string{
+ childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
+ }
+
+ if ctx.Result.ToolCallID == "" {
+ // Pending state - would show animation in TUI.
+ parts = append(parts, "", s.Subtle.Render("Working..."))
+ }
+
+ header = lipgloss.JoinVertical(
+ lipgloss.Left,
+ parts...,
+ )
+
+ if ctx.Result.ToolCallID == "" {
+ return header
+ }
+
+ body := renderMarkdownContent(ctx, ctx.Result.Content)
+ return joinHeaderBody(ctx, header, body)
+}
+
+func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
+ return func(children tree.Children, i int) string {
+ if children.Length()-1 == i {
+ return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
+ }
+ return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
+ }
+}