diff --git a/internal/ui/common/diff.go b/internal/ui/common/diff.go new file mode 100644 index 0000000000000000000000000000000000000000..8007cebce93a0d0833be779eb11cbb703bc8c1d6 --- /dev/null +++ b/internal/ui/common/diff.go @@ -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 +} diff --git a/internal/ui/common/highlight.go b/internal/ui/common/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..642a7859d110a86af57feeb447907612a6b12098 --- /dev/null +++ b/internal/ui/common/highlight.go @@ -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 +} diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index bc38a250876eab39eba7f1ffdba124741ee2ed5d..9b3f4967f374380b46bfcc813136597c3812c26f 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -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) } } diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go new file mode 100644 index 0000000000000000000000000000000000000000..7df9f8c052400da3e9198513dcd37bc4d67d41ec --- /dev/null +++ b/internal/ui/model/items.go @@ -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 +} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 22eac673a41e76874b9d9f04e31d313eb1fb09fa..09c3155dfd597ab55175f0ac079ce03e69494b5b 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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. diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 59be32af0deabfcfd749f72edc7d4493b8ed8870..97d3b4950f2672481a949a7538c00fa2579c3065 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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 +} diff --git a/internal/ui/toolrender/render.go b/internal/ui/toolrender/render.go new file mode 100644 index 0000000000000000000000000000000000000000..18895908583322a06e58de553a6291ba9f7b3448 --- /dev/null +++ b/internal/ui/toolrender/render.go @@ -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) + " " + } +}