Detailed changes
@@ -0,0 +1,181 @@
+package chat
+
+import (
+ "fmt"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "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"
+)
+
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message that can be displayed
+// in the chat UI.
+type AssistantMessageItem struct {
+ id string
+ content string
+ thinking string
+ finished bool
+ finish message.Finish
+ sty *styles.Styles
+ thinkingExpanded bool
+ thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessage creates a new assistant message item.
+func NewAssistantMessage(id, content, thinking string, finished bool, finish message.Finish, sty *styles.Styles) *AssistantMessageItem {
+ return &AssistantMessageItem{
+ id: id,
+ content: content,
+ thinking: thinking,
+ finished: finished,
+ finish: finish,
+ sty: sty,
+ }
+}
+
+// ID implements Identifiable.
+func (m *AssistantMessageItem) ID() string {
+ return m.id
+}
+
+// FocusStyle returns the focus style.
+func (m *AssistantMessageItem) FocusStyle() lipgloss.Style {
+ return m.sty.Chat.Message.AssistantFocused
+}
+
+// BlurStyle returns the blur style.
+func (m *AssistantMessageItem) BlurStyle() lipgloss.Style {
+ return m.sty.Chat.Message.AssistantBlurred
+}
+
+// HighlightStyle returns the highlight style.
+func (m *AssistantMessageItem) HighlightStyle() lipgloss.Style {
+ return m.sty.TextSelection
+}
+
+// Render implements list.Item.
+func (m *AssistantMessageItem) Render(width int) string {
+ cappedWidth := min(width, maxTextWidth)
+ content := strings.TrimSpace(m.content)
+ thinking := strings.TrimSpace(m.thinking)
+
+ // Handle empty finished messages.
+ if m.finished && content == "" {
+ switch m.finish.Reason {
+ case message.FinishReasonEndTurn:
+ return ""
+ case message.FinishReasonCanceled:
+ return m.renderMarkdown("*Canceled*", cappedWidth)
+ case message.FinishReasonError:
+ return m.renderError(cappedWidth)
+ }
+ }
+
+ var parts []string
+
+ // Render thinking content if present.
+ if thinking != "" {
+ parts = append(parts, m.renderThinking(thinking, cappedWidth))
+ }
+
+ // Render main content.
+ if content != "" {
+ if len(parts) > 0 {
+ parts = append(parts, "")
+ }
+ parts = append(parts, m.renderMarkdown(content, cappedWidth))
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, parts...)
+}
+
+// renderMarkdown renders content as markdown.
+func (m *AssistantMessageItem) renderMarkdown(content string, width int) string {
+ renderer := common.MarkdownRenderer(m.sty, width)
+ result, err := renderer.Render(content)
+ if err != nil {
+ return content
+ }
+ return strings.TrimSuffix(result, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content.
+func (m *AssistantMessageItem) renderThinking(thinking string, width int) string {
+ renderer := common.PlainMarkdownRenderer(m.sty, width-2)
+ rendered, err := renderer.Render(thinking)
+ if err != nil {
+ rendered = thinking
+ }
+ rendered = strings.TrimSpace(rendered)
+
+ lines := strings.Split(rendered, "\n")
+ totalLines := len(lines)
+
+ // Collapse if not expanded and exceeds max height.
+ isTruncated := totalLines > maxCollapsedThinkingHeight
+ if !m.thinkingExpanded && isTruncated {
+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
+ }
+
+ // Add hint if truncated and not expanded.
+ if !m.thinkingExpanded && isTruncated {
+ hint := m.sty.Muted.Render(fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight))
+ lines = append([]string{hint}, lines...)
+ }
+
+ thinkingStyle := m.sty.Subtle.Background(m.sty.BgBaseLighter).Width(width)
+ result := thinkingStyle.Render(strings.Join(lines, "\n"))
+
+ // Track the rendered height for click detection.
+ m.thinkingBoxHeight = lipgloss.Height(result)
+
+ return result
+}
+
+// HandleMouseClick implements list.MouseClickable.
+func (m *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+ // Only handle left clicks.
+ if btn != ansi.MouseLeft {
+ return false
+ }
+
+ // Check if click is within the thinking box area.
+ if m.thinking != "" && y < m.thinkingBoxHeight {
+ m.thinkingExpanded = !m.thinkingExpanded
+ return true
+ }
+
+ return false
+}
+
+// HandleKeyPress implements list.KeyPressable.
+func (m *AssistantMessageItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
+ // Only handle space key on thinking content.
+ if m.thinking == "" {
+ return false
+ }
+
+ if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
+ // Toggle thinking expansion.
+ m.thinkingExpanded = !m.thinkingExpanded
+ return true
+ }
+
+ return false
+}
+
+// renderError renders an error message.
+func (m *AssistantMessageItem) renderError(width int) string {
+ errTag := m.sty.Chat.Message.ErrorTag.Render("ERROR")
+ truncated := ansi.Truncate(m.finish.Message, width-2-lipgloss.Width(errTag), "...")
+ title := fmt.Sprintf("%s %s", errTag, m.sty.Chat.Message.ErrorTitle.Render(truncated))
+ details := m.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(m.finish.Details)
+ return fmt.Sprintf("%s\n\n%s", title, details)
+}
@@ -1,11 +1,29 @@
-package model
+// Package chat provides the chat UI components for displaying and managing
+// conversation messages between users and assistants.
+package chat
import (
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/list"
uv "github.com/charmbracelet/ultraviolet"
)
+// maxTextWidth is the maximum width text messages can be
+const maxTextWidth = 120
+
+// 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
+ Identifiable
+}
+
// Chat represents the chat UI model that handles chat interactions and
// messages.
type Chat struct {
@@ -159,3 +177,8 @@ func (m *Chat) HandleMouseUp(x, y int) {
func (m *Chat) HandleMouseDrag(x, y int) {
m.list.HandleMouseDrag(x, y)
}
+
+// HandleKeyPress handles key press events for the currently selected item.
+func (m *Chat) HandleKeyPress(msg tea.KeyPressMsg) bool {
+ return m.list.HandleKeyPress(msg)
+}
@@ -0,0 +1,56 @@
+package chat
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/google/uuid"
+)
+
+// SectionItem represents a section separator showing model info and response time.
+type SectionItem struct {
+ id string
+ msg message.Message
+ lastUserMessageTime time.Time
+ modelName string
+ sty *styles.Styles
+}
+
+// NewSectionItem creates a new section item showing assistant response metadata.
+func NewSectionItem(msg message.Message, lastUserMessageTime time.Time, modelName string, sty *styles.Styles) *SectionItem {
+ return &SectionItem{
+ id: uuid.NewString(),
+ msg: msg,
+ lastUserMessageTime: lastUserMessageTime,
+ modelName: modelName,
+ sty: sty,
+ }
+}
+
+// ID implements Identifiable.
+func (m *SectionItem) ID() string {
+ return m.id
+}
+
+// Render implements list.Item.
+func (m *SectionItem) Render(width int) string {
+ finishData := m.msg.FinishPart()
+ if finishData == nil {
+ return ""
+ }
+
+ finishTime := time.Unix(finishData.Time, 0)
+ duration := finishTime.Sub(m.lastUserMessageTime)
+
+ icon := m.sty.Chat.Message.SectionIcon.Render(styles.ModelIcon)
+ modelFormatted := m.sty.Chat.Message.SectionModel.Render(m.modelName)
+ durationFormatted := m.sty.Chat.Message.SectionDuration.Render(duration.String())
+
+ text := fmt.Sprintf("%s %s %s", icon, modelFormatted, durationFormatted)
+
+ section := common.Section(m.sty, text, width-2)
+ return m.sty.Chat.Message.SectionHeader.Render(section)
+}
@@ -0,0 +1,610 @@
+package chat
+
+import (
+ "encoding/json"
+ "fmt"
+ "slices"
+ "strings"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/ansiext"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/session"
+ "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
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+ ToolStatusAwaitingPermission ToolStatus = iota
+ ToolStatusRunning
+ ToolStatusSuccess
+ ToolStatusError
+ ToolStatusCancelled
+)
+
+// ToolCallContext provides the context needed for rendering a tool call.
+type ToolCallContext struct {
+ Call message.ToolCall
+ Result *message.ToolResult
+ Cancelled bool
+ PermissionRequested bool
+ PermissionGranted bool
+ IsNested bool
+ Styles *styles.Styles
+
+ // NestedCalls holds child tool calls for agent/agentic_fetch.
+ NestedCalls []ToolCallContext
+}
+
+// Status returns the current status of the tool call.
+func (ctx *ToolCallContext) Status() ToolStatus {
+ if ctx.Cancelled {
+ return ToolStatusCancelled
+ }
+ if ctx.HasResult() {
+ if ctx.Result.IsError {
+ return ToolStatusError
+ }
+ return ToolStatusSuccess
+ }
+ // No result yet - check permission state.
+ if ctx.PermissionRequested && !ctx.PermissionGranted {
+ return ToolStatusAwaitingPermission
+ }
+ return ToolStatusRunning
+}
+
+// HasResult returns true if the tool call has a completed result.
+func (ctx *ToolCallContext) HasResult() bool {
+ return ctx.Result != nil && ctx.Result.ToolCallID != ""
+}
+
+// toolStyles provides common FocusStylable and HighlightStylable implementations.
+// Embed this in tool items to avoid repeating style methods.
+type toolStyles struct {
+ sty *styles.Styles
+}
+
+func (s toolStyles) FocusStyle() lipgloss.Style {
+ return s.sty.Chat.Message.ToolCallFocused
+}
+
+func (s toolStyles) BlurStyle() lipgloss.Style {
+ return s.sty.Chat.Message.ToolCallBlurred
+}
+
+func (s toolStyles) HighlightStyle() lipgloss.Style {
+ return s.sty.TextSelection
+}
+
+// toolItem provides common base functionality for all tool items.
+type toolItem struct {
+ toolStyles
+ id string
+ expanded bool // Whether truncated content is expanded.
+ wasTruncated bool // Whether the last render was truncated.
+}
+
+// newToolItem creates a new toolItem with the given context.
+func newToolItem(ctx ToolCallContext) toolItem {
+ return toolItem{
+ toolStyles: toolStyles{sty: ctx.Styles},
+ id: ctx.Call.ID,
+ }
+}
+
+// ID implements Identifiable.
+func (t *toolItem) ID() string {
+ return t.id
+}
+
+// HandleMouseClick implements list.MouseClickable.
+func (t *toolItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+ // Only handle left clicks on truncated content.
+ if btn != ansi.MouseLeft || !t.wasTruncated {
+ return false
+ }
+
+ // Toggle expansion.
+ t.expanded = !t.expanded
+ return true
+}
+
+// HandleKeyPress implements list.KeyPressable.
+func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
+ // Only handle space key on truncated content.
+ if !t.wasTruncated {
+ return false
+ }
+
+ if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
+ // Toggle expansion.
+ t.expanded = !t.expanded
+ return true
+ }
+
+ return false
+}
+
+// unmarshalParams unmarshals JSON input into the target struct.
+func unmarshalParams(input string, target any) error {
+ return json.Unmarshal([]byte(input), target)
+}
+
+// ParamBuilder helps construct parameter lists for tool headers.
+type ParamBuilder struct {
+ args []string
+}
+
+// NewParamBuilder creates a new parameter builder.
+func NewParamBuilder() *ParamBuilder {
+ return &ParamBuilder{args: make([]string, 0, 4)}
+}
+
+// Main adds the main parameter (first positional argument).
+func (pb *ParamBuilder) Main(value string) *ParamBuilder {
+ if value != "" {
+ pb.args = append(pb.args, value)
+ }
+ return pb
+}
+
+// KeyValue adds a key-value pair parameter.
+func (pb *ParamBuilder) KeyValue(key, value string) *ParamBuilder {
+ if value != "" {
+ pb.args = append(pb.args, key, value)
+ }
+ return pb
+}
+
+// Flag adds a boolean flag parameter (only if true).
+func (pb *ParamBuilder) Flag(key string, value bool) *ParamBuilder {
+ if value {
+ pb.args = append(pb.args, key, "true")
+ }
+ return pb
+}
+
+// Build returns the parameter list.
+func (pb *ParamBuilder) Build() []string {
+ return pb.args
+}
+
+// renderToolIcon returns the status icon for a tool call.
+func renderToolIcon(status ToolStatus, sty *styles.Styles) string {
+ switch status {
+ case ToolStatusSuccess:
+ return sty.Tool.IconSuccess.String()
+ case ToolStatusError:
+ return sty.Tool.IconError.String()
+ case ToolStatusCancelled:
+ return sty.Tool.IconCancelled.String()
+ default:
+ return sty.Tool.IconPending.String()
+ }
+}
+
+// renderToolHeader builds the tool header line: "● ToolName params..."
+func renderToolHeader(ctx *ToolCallContext, name string, width int, params ...string) string {
+ sty := ctx.Styles
+ icon := renderToolIcon(ctx.Status(), sty)
+
+ var toolName string
+ if ctx.IsNested {
+ toolName = sty.Tool.NameNested.Render(name)
+ } else {
+ toolName = sty.Tool.NameNormal.Render(name)
+ }
+
+ prefix := fmt.Sprintf("%s %s ", icon, toolName)
+ prefixWidth := lipgloss.Width(prefix)
+ remainingWidth := width - prefixWidth
+
+ paramsStr := renderParamList(params, remainingWidth, sty)
+ return prefix + paramsStr
+}
+
+// renderParamList formats parameters as "main (key=value, ...)" with truncation.
+func renderParamList(params []string, width int, sty *styles.Styles) string {
+ if len(params) == 0 {
+ return ""
+ }
+
+ mainParam := params[0]
+ if width >= 0 && lipgloss.Width(mainParam) > width {
+ mainParam = ansi.Truncate(mainParam, width, "…")
+ }
+
+ if len(params) == 1 {
+ return sty.Tool.ParamMain.Render(mainParam)
+ }
+
+ // Build key=value pairs from remaining params.
+ otherParams := params[1:]
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+
+ var parts []string
+ 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))
+ }
+
+ if len(parts) == 0 {
+ return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := width - lipgloss.Width(partsRendered) - 3 // " ()"
+ if remainingWidth < 30 {
+ // Not enough space for params, just show main.
+ return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
+ }
+
+ fullParam := fmt.Sprintf("%s (%s)", mainParam, partsRendered)
+ return sty.Tool.ParamMain.Render(ansi.Truncate(fullParam, width, "…"))
+}
+
+// renderEarlyState handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func renderEarlyState(ctx *ToolCallContext, header string, width int) (string, bool) {
+ sty := ctx.Styles
+
+ var msg string
+ switch ctx.Status() {
+ case ToolStatusError:
+ msg = renderToolError(ctx, width)
+ case ToolStatusCancelled:
+ msg = sty.Tool.StateCancelled.Render("Canceled.")
+ case ToolStatusAwaitingPermission:
+ msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+ case ToolStatusRunning:
+ msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+ default:
+ return "", false
+ }
+
+ msg = sty.Tool.BodyPadding.Render(msg)
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", msg), true
+}
+
+// renderToolError formats an error message with ERROR tag.
+func renderToolError(ctx *ToolCallContext, width int) string {
+ sty := ctx.Styles
+ errContent := strings.ReplaceAll(ctx.Result.Content, "\n", " ")
+ errTag := sty.Tool.ErrorTag.Render("ERROR")
+ tagWidth := lipgloss.Width(errTag)
+ errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+ return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// joinHeaderBody combines header and body with proper padding.
+func joinHeaderBody(header, body string, sty *styles.Styles) string {
+ if body == "" {
+ return header
+ }
+ body = sty.Tool.BodyPadding.Render(body)
+ return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
+}
+
+// renderPlainContent renders plain text with optional expansion support.
+func renderPlainContent(content string, width int, sty *styles.Styles, item *toolItem) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+ lines := strings.Split(content, "\n")
+
+ expanded := item != nil && item.expanded
+ maxLines := responseContextHeight
+ if expanded {
+ maxLines = len(lines) // Show all
+ }
+
+ var out []string
+ for i, ln := range lines {
+ if i >= maxLines {
+ break
+ }
+ ln = " " + ln
+ if lipgloss.Width(ln) > width {
+ ln = ansi.Truncate(ln, width, "…")
+ }
+ out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+ }
+
+ wasTruncated := len(lines) > responseContextHeight
+ if item != nil {
+ item.wasTruncated = wasTruncated
+ }
+
+ if !expanded && wasTruncated {
+ out = append(out, sty.Tool.ContentTruncation.
+ Width(width).
+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+ }
+
+ return strings.Join(out, "\n")
+}
+
+// formatNonZero returns string representation of non-zero integers, empty for zero.
+func formatNonZero(value int) string {
+ if value == 0 {
+ return ""
+ }
+ return fmt.Sprintf("%d", value)
+}
+
+// renderCodeContent renders syntax-highlighted code with line numbers and optional expansion.
+func renderCodeContent(path, content string, offset, width int, sty *styles.Styles, item *toolItem) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+
+ lines := strings.Split(content, "\n")
+
+ maxLines := responseContextHeight
+ if item != nil && item.expanded {
+ maxLines = len(lines)
+ }
+
+ truncated := lines
+ if len(lines) > maxLines {
+ truncated = lines[:maxLines]
+ }
+
+ // Escape ANSI sequences in content.
+ for i, ln := range truncated {
+ truncated[i] = ansiext.Escape(ln)
+ }
+
+ // Apply syntax highlighting.
+ bg := sty.Tool.ContentCodeBg
+ highlighted, _ := common.SyntaxHighlight(sty, strings.Join(truncated, "\n"), path, bg)
+ highlightedLines := strings.Split(highlighted, "\n")
+
+ // Calculate gutter width for line numbers.
+ maxLineNum := offset + len(highlightedLines)
+ maxDigits := getDigits(maxLineNum)
+ numFmt := fmt.Sprintf("%%%dd", maxDigits)
+
+ // Calculate available width for code (accounting for gutter).
+ const numPR, numPL, codePR, codePL = 1, 1, 1, 2
+ codeWidth := width - maxDigits - numPL - numPR - 2
+
+ var out []string
+ for i, ln := range highlightedLines {
+ lineNum := sty.Base.
+ Foreground(sty.FgMuted).
+ Background(bg).
+ PaddingRight(numPR).
+ PaddingLeft(numPL).
+ Render(fmt.Sprintf(numFmt, offset+i+1))
+
+ codeLine := sty.Base.
+ Width(codeWidth).
+ Background(bg).
+ PaddingRight(codePR).
+ PaddingLeft(codePL).
+ Render(ansi.Truncate(ln, codeWidth-codePL-codePR, "…"))
+
+ out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
+ }
+
+ wasTruncated := len(lines) > responseContextHeight
+ if item != nil {
+ item.wasTruncated = wasTruncated
+ }
+
+ expanded := item != nil && item.expanded
+
+ if !expanded && wasTruncated {
+ msg := fmt.Sprintf(" …(%d lines) [click or space to expand]", len(lines)-responseContextHeight)
+ out = append(out, sty.Muted.Background(bg).Render(msg))
+ }
+
+ return lipgloss.JoinVertical(lipgloss.Left, out...)
+}
+
+// renderMarkdownContent renders markdown with optional expansion support.
+func renderMarkdownContent(content string, width int, sty *styles.Styles, item *toolItem) string {
+ content = strings.ReplaceAll(content, "\r\n", "\n")
+ content = strings.ReplaceAll(content, "\t", " ")
+ content = strings.TrimSpace(content)
+
+ cappedWidth := min(width, 120)
+ renderer := common.PlainMarkdownRenderer(sty, cappedWidth)
+ rendered, err := renderer.Render(content)
+ if err != nil {
+ return renderPlainContent(content, width, sty, nil)
+ }
+
+ lines := strings.Split(rendered, "\n")
+
+ maxLines := responseContextHeight
+ if item != nil && item.expanded {
+ maxLines = len(lines)
+ }
+
+ var out []string
+ for i, ln := range lines {
+ if i >= maxLines {
+ break
+ }
+ out = append(out, ln)
+ }
+
+ wasTruncated := len(lines) > responseContextHeight
+ if item != nil {
+ item.wasTruncated = wasTruncated
+ }
+
+ expanded := item != nil && item.expanded
+
+ if !expanded && wasTruncated {
+ out = append(out, sty.Tool.ContentTruncation.
+ Width(cappedWidth-2).
+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+ }
+
+ return sty.Tool.ContentLine.Render(strings.Join(out, "\n"))
+}
+
+// renderDiffContent renders a diff with optional expansion support.
+func renderDiffContent(file, oldContent, newContent string, width int, sty *styles.Styles, item *toolItem) string {
+ formatter := common.DiffFormatter(sty).
+ Before(file, oldContent).
+ After(file, newContent).
+ Width(width)
+
+ if width > 120 {
+ formatter = formatter.Split()
+ }
+
+ formatted := formatter.String()
+ lines := strings.Split(formatted, "\n")
+
+ wasTruncated := len(lines) > responseContextHeight
+ if item != nil {
+ item.wasTruncated = wasTruncated
+ }
+
+ expanded := item != nil && item.expanded
+
+ if !expanded && wasTruncated {
+ truncateMsg := sty.Tool.DiffTruncation.
+ Width(width).
+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))
+ formatted = strings.Join(lines[:responseContextHeight], "\n") + "\n" + truncateMsg
+ }
+
+ return formatted
+}
+
+// renderImageContent renders image data with optional text content.
+func renderImageContent(data, mediaType, textContent string, sty *styles.Styles) string {
+ dataSize := len(data) * 3 / 4 // Base64 to bytes approximation.
+ sizeStr := formatSize(dataSize)
+
+ loaded := sty.Tool.IconSuccess.String()
+ arrow := sty.Tool.NameNested.Render("→")
+ typeStyled := sty.Base.Render(mediaType)
+ sizeStyled := sty.Subtle.Render(sizeStr)
+
+ imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
+
+ if strings.TrimSpace(textContent) != "" {
+ textDisplay := sty.Tool.ContentLine.Render(textContent)
+ return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
+ }
+
+ return imageDisplay
+}
+
+// renderMediaContent renders non-image media content.
+func renderMediaContent(mediaType, textContent string, sty *styles.Styles) string {
+ loaded := sty.Tool.IconSuccess.String()
+ arrow := sty.Tool.NameNested.Render("→")
+ typeStyled := sty.Base.Render(mediaType)
+ mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
+
+ if strings.TrimSpace(textContent) != "" {
+ textDisplay := sty.Tool.ContentLine.Render(textContent)
+ return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
+ }
+
+ return mediaDisplay
+}
+
+// formatSize formats byte count as human-readable size.
+func formatSize(bytes int) string {
+ if bytes < 1024 {
+ return fmt.Sprintf("%d B", bytes)
+ }
+ if bytes < 1024*1024 {
+ return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
+ }
+ return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
+}
+
+// getDigits returns the number of digits in a number.
+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
+}
+
+// formatTodosList formats a list of todos with status icons.
+func formatTodosList(todos []session.Todo, width int, sty *styles.Styles) string {
+ if len(todos) == 0 {
+ return ""
+ }
+
+ sorted := make([]session.Todo, len(todos))
+ copy(sorted, todos)
+ slices.SortStableFunc(sorted, func(a, b session.Todo) int {
+ return todoStatusOrder(a.Status) - todoStatusOrder(b.Status)
+ })
+
+ var lines []string
+ for _, todo := range sorted {
+ var prefix string
+ var textStyle lipgloss.Style
+
+ switch todo.Status {
+ case session.TodoStatusCompleted:
+ prefix = sty.Base.Foreground(sty.Green).Render(styles.TodoCompletedIcon) + " "
+ textStyle = sty.Base
+ case session.TodoStatusInProgress:
+ prefix = sty.Base.Foreground(sty.GreenDark).Render(styles.ArrowRightIcon) + " "
+ textStyle = sty.Base
+ default:
+ prefix = sty.Muted.Render(styles.TodoPendingIcon) + " "
+ textStyle = sty.Base
+ }
+
+ text := todo.Content
+ if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
+ text = todo.ActiveForm
+ }
+
+ line := prefix + textStyle.Render(text)
+ line = ansi.Truncate(line, width, "…")
+ lines = append(lines, line)
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+// todoStatusOrder returns sort order for todo statuses.
+func todoStatusOrder(s session.TodoStatus) int {
+ switch s {
+ case session.TodoStatusCompleted:
+ return 0
+ case session.TodoStatusInProgress:
+ return 1
+ default:
+ return 2
+ }
+}
@@ -0,0 +1,1100 @@
+package chat
+
+import (
+ "cmp"
+ "fmt"
+ "strings"
+ "time"
+
+ "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/fsext"
+)
+
+// NewToolItem creates the appropriate tool item for the given context.
+func NewToolItem(ctx ToolCallContext) MessageItem {
+ switch ctx.Call.Name {
+ // Bash tools
+ case tools.BashToolName:
+ return NewBashToolItem(ctx)
+ case tools.JobOutputToolName:
+ return NewJobOutputToolItem(ctx)
+ case tools.JobKillToolName:
+ return NewJobKillToolItem(ctx)
+
+ // File tools
+ case tools.ViewToolName:
+ return NewViewToolItem(ctx)
+ case tools.EditToolName:
+ return NewEditToolItem(ctx)
+ case tools.MultiEditToolName:
+ return NewMultiEditToolItem(ctx)
+ case tools.WriteToolName:
+ return NewWriteToolItem(ctx)
+
+ // Search tools
+ case tools.GlobToolName:
+ return NewGlobToolItem(ctx)
+ case tools.GrepToolName:
+ return NewGrepToolItem(ctx)
+ case tools.LSToolName:
+ return NewLSToolItem(ctx)
+ case tools.SourcegraphToolName:
+ return NewSourcegraphToolItem(ctx)
+
+ // Fetch tools
+ case tools.FetchToolName:
+ return NewFetchToolItem(ctx)
+ case tools.AgenticFetchToolName:
+ return NewAgenticFetchToolItem(ctx)
+ case tools.WebFetchToolName:
+ return NewWebFetchToolItem(ctx)
+ case tools.WebSearchToolName:
+ return NewWebSearchToolItem(ctx)
+ case tools.DownloadToolName:
+ return NewDownloadToolItem(ctx)
+
+ // LSP tools
+ case tools.DiagnosticsToolName:
+ return NewDiagnosticsToolItem(ctx)
+ case tools.ReferencesToolName:
+ return NewReferencesToolItem(ctx)
+
+ // Misc tools
+ case tools.TodosToolName:
+ return NewTodosToolItem(ctx)
+ case agent.AgentToolName:
+ return NewAgentToolItem(ctx)
+
+ default:
+ return NewGenericToolItem(ctx)
+ }
+}
+
+// -----------------------------------------------------------------------------
+// Bash Tools
+// -----------------------------------------------------------------------------
+
+// BashToolItem renders bash command execution.
+type BashToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewBashToolItem(ctx ToolCallContext) *BashToolItem {
+ return &BashToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *BashToolItem) Render(width int) string {
+ var params tools.BashParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ cmd := strings.ReplaceAll(params.Command, "\n", " ")
+ cmd = strings.ReplaceAll(cmd, "\t", " ")
+
+ // Check if this is a background job that finished
+ if m.ctx.Call.Finished {
+ var meta tools.BashResponseMetadata
+ unmarshalParams(m.ctx.Result.Metadata, &meta)
+ if meta.Background {
+ return m.renderBackgroundJob(params, meta, width)
+ }
+ }
+
+ args := NewParamBuilder().
+ Main(cmd).
+ Flag("background", params.RunInBackground).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Bash", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ var meta tools.BashResponseMetadata
+ unmarshalParams(m.ctx.Result.Metadata, &meta)
+
+ output := meta.Output
+ if output == "" && m.ctx.Result.Content != tools.BashNoOutput {
+ output = m.ctx.Result.Content
+ }
+
+ if output == "" {
+ return header
+ }
+
+ body := renderPlainContent(output, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+func (m *BashToolItem) renderBackgroundJob(params tools.BashParams, meta tools.BashResponseMetadata, width int) string {
+ description := cmp.Or(meta.Description, params.Command)
+ header := renderJobHeader(&m.ctx, "Start", meta.ShellID, description, width)
+
+ if m.ctx.IsNested {
+ return header
+ }
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ content := "Command: " + params.Command + "\n" + m.ctx.Result.Content
+ body := renderPlainContent(content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// JobOutputToolItem renders job output retrieval.
+type JobOutputToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewJobOutputToolItem(ctx ToolCallContext) *JobOutputToolItem {
+ return &JobOutputToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *JobOutputToolItem) Render(width int) string {
+ var params tools.JobOutputParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ var meta tools.JobOutputResponseMetadata
+ var description string
+ if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+ unmarshalParams(m.ctx.Result.Metadata, &meta)
+ description = cmp.Or(meta.Description, meta.Command)
+ }
+
+ header := renderJobHeader(&m.ctx, "Output", params.ShellID, description, width)
+
+ if m.ctx.IsNested {
+ return header
+ }
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// JobKillToolItem renders job termination.
+type JobKillToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewJobKillToolItem(ctx ToolCallContext) *JobKillToolItem {
+ return &JobKillToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *JobKillToolItem) Render(width int) string {
+ var params tools.JobKillParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ var meta tools.JobKillResponseMetadata
+ var description string
+ if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+ unmarshalParams(m.ctx.Result.Metadata, &meta)
+ description = cmp.Or(meta.Description, meta.Command)
+ }
+
+ header := renderJobHeader(&m.ctx, "Kill", params.ShellID, description, width)
+
+ if m.ctx.IsNested {
+ return header
+ }
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// renderJobHeader builds a job-specific header with action and PID.
+func renderJobHeader(ctx *ToolCallContext, action, pid, description string, width int) string {
+ sty := ctx.Styles
+ icon := renderToolIcon(ctx.Status(), sty)
+
+ jobPart := sty.Tool.JobToolName.Render("Job")
+ actionPart := sty.Tool.JobAction.Render("(" + action + ")")
+ pidPart := sty.Tool.JobPID.Render("PID " + pid)
+
+ prefix := fmt.Sprintf("%s %s %s %s", icon, jobPart, actionPart, pidPart)
+
+ if description == "" {
+ return prefix
+ }
+
+ descPart := " " + sty.Tool.JobDescription.Render(description)
+ fullHeader := prefix + descPart
+
+ if lipgloss.Width(fullHeader) > width {
+ availableWidth := width - lipgloss.Width(prefix) - 1
+ if availableWidth < 10 {
+ return prefix
+ }
+ descPart = " " + sty.Tool.JobDescription.Render(truncateText(description, availableWidth))
+ fullHeader = prefix + descPart
+ }
+
+ return fullHeader
+}
+
+// -----------------------------------------------------------------------------
+// File Tools
+// -----------------------------------------------------------------------------
+
+// ViewToolItem renders file viewing with syntax highlighting.
+type ViewToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewViewToolItem(ctx ToolCallContext) *ViewToolItem {
+ return &ViewToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *ViewToolItem) Render(width int) string {
+ var params tools.ViewParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := NewParamBuilder().
+ Main(file).
+ KeyValue("limit", formatNonZero(params.Limit)).
+ KeyValue("offset", formatNonZero(params.Offset)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "View", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ // Handle image content
+ if m.ctx.Result.Data != "" && strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
+ body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, "", m.ctx.Styles)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+ }
+
+ var meta tools.ViewResponseMetadata
+ unmarshalParams(m.ctx.Result.Metadata, &meta)
+
+ body := renderCodeContent(meta.FilePath, meta.Content, params.Offset, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// EditToolItem renders file editing with diff visualization.
+type EditToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewEditToolItem(ctx ToolCallContext) *EditToolItem {
+ return &EditToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *EditToolItem) Render(width int) string {
+ var params tools.EditParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := NewParamBuilder().Main(file).Build()
+
+ header := renderToolHeader(&m.ctx, "Edit", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ var meta tools.EditResponseMetadata
+ if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err != nil {
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, nil)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+ }
+
+ body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// MultiEditToolItem renders multiple file edits with diff visualization.
+type MultiEditToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewMultiEditToolItem(ctx ToolCallContext) *MultiEditToolItem {
+ return &MultiEditToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *MultiEditToolItem) Render(width int) string {
+ var params tools.MultiEditParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := NewParamBuilder().
+ Main(file).
+ KeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Multi-Edit", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ var meta tools.MultiEditResponseMetadata
+ if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err != nil {
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, nil)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+ }
+
+ body := renderDiffContent(file, meta.OldContent, meta.NewContent, width-2, m.ctx.Styles, &m.toolItem)
+
+ // Add failed edits warning if any exist
+ if len(meta.EditsFailed) > 0 {
+ sty := m.ctx.Styles
+ noteTag := sty.Tool.NoteTag.Render("Note")
+ noteMsg := fmt.Sprintf("%d of %d edits succeeded", meta.EditsApplied, len(params.Edits))
+ note := fmt.Sprintf("%s %s", noteTag, sty.Tool.NoteMessage.Render(noteMsg))
+ body = lipgloss.JoinVertical(lipgloss.Left, body, "", note)
+ }
+
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// WriteToolItem renders file writing with syntax-highlighted content preview.
+type WriteToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewWriteToolItem(ctx ToolCallContext) *WriteToolItem {
+ return &WriteToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *WriteToolItem) Render(width int) string {
+ var params tools.WriteParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ file := fsext.PrettyPath(params.FilePath)
+ args := NewParamBuilder().Main(file).Build()
+
+ header := renderToolHeader(&m.ctx, "Write", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderCodeContent(file, params.Content, 0, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Search Tools
+// -----------------------------------------------------------------------------
+
+// GlobToolItem renders glob file pattern matching results.
+type GlobToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewGlobToolItem(ctx ToolCallContext) *GlobToolItem {
+ return &GlobToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *GlobToolItem) Render(width int) string {
+ var params tools.GlobParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.Pattern).
+ KeyValue("path", fsext.PrettyPath(params.Path)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Glob", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// GrepToolItem renders grep content search results.
+type GrepToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewGrepToolItem(ctx ToolCallContext) *GrepToolItem {
+ return &GrepToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *GrepToolItem) Render(width int) string {
+ var params tools.GrepParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.Pattern).
+ KeyValue("path", fsext.PrettyPath(params.Path)).
+ KeyValue("include", params.Include).
+ Flag("literal", params.LiteralText).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Grep", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// LSToolItem renders directory listing results.
+type LSToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewLSToolItem(ctx ToolCallContext) *LSToolItem {
+ return &LSToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *LSToolItem) Render(width int) string {
+ var params tools.LSParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ path := cmp.Or(params.Path, ".")
+ path = fsext.PrettyPath(path)
+
+ args := NewParamBuilder().Main(path).Build()
+ header := renderToolHeader(&m.ctx, "List", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// SourcegraphToolItem renders code search results.
+type SourcegraphToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewSourcegraphToolItem(ctx ToolCallContext) *SourcegraphToolItem {
+ return &SourcegraphToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *SourcegraphToolItem) Render(width int) string {
+ var params tools.SourcegraphParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.Query).
+ KeyValue("count", formatNonZero(params.Count)).
+ KeyValue("context", formatNonZero(params.ContextWindow)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Sourcegraph", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Fetch Tools
+// -----------------------------------------------------------------------------
+
+// FetchToolItem renders URL fetching with format-specific content display.
+type FetchToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewFetchToolItem(ctx ToolCallContext) *FetchToolItem {
+ return &FetchToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *FetchToolItem) Render(width int) string {
+ var params tools.FetchParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.URL).
+ KeyValue("format", params.Format).
+ KeyValue("timeout", formatTimeout(params.Timeout)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Fetch", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ // Use appropriate extension for syntax highlighting
+ file := "fetch.md"
+ switch params.Format {
+ case "text":
+ file = "fetch.txt"
+ case "html":
+ file = "fetch.html"
+ }
+
+ body := renderCodeContent(file, m.ctx.Result.Content, 0, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// AgenticFetchToolItem renders agentic URL fetching with nested tool calls.
+type AgenticFetchToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewAgenticFetchToolItem(ctx ToolCallContext) *AgenticFetchToolItem {
+ return &AgenticFetchToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *AgenticFetchToolItem) Render(width int) string {
+ var params tools.AgenticFetchParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ var args []string
+ if params.URL != "" {
+ args = NewParamBuilder().Main(params.URL).Build()
+ }
+
+ header := renderToolHeader(&m.ctx, "Agentic Fetch", width, args...)
+
+ // Render with nested tool calls tree
+ body := renderAgentBody(&m.ctx, params.Prompt, "Prompt", header, width)
+ return body
+}
+
+// WebFetchToolItem renders web page fetching.
+type WebFetchToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewWebFetchToolItem(ctx ToolCallContext) *WebFetchToolItem {
+ return &WebFetchToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *WebFetchToolItem) Render(width int) string {
+ var params tools.WebFetchParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().Main(params.URL).Build()
+ header := renderToolHeader(&m.ctx, "Fetch", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderMarkdownContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// WebSearchToolItem renders web search results.
+type WebSearchToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewWebSearchToolItem(ctx ToolCallContext) *WebSearchToolItem {
+ return &WebSearchToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *WebSearchToolItem) Render(width int) string {
+ var params tools.WebSearchParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().Main(params.Query).Build()
+ header := renderToolHeader(&m.ctx, "Search", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderMarkdownContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// DownloadToolItem renders file downloading.
+type DownloadToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewDownloadToolItem(ctx ToolCallContext) *DownloadToolItem {
+ return &DownloadToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *DownloadToolItem) Render(width int) string {
+ var params tools.DownloadParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.URL).
+ KeyValue("file_path", fsext.PrettyPath(params.FilePath)).
+ KeyValue("timeout", formatTimeout(params.Timeout)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "Download", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// LSP Tools
+// -----------------------------------------------------------------------------
+
+// DiagnosticsToolItem renders project-wide diagnostic information.
+type DiagnosticsToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewDiagnosticsToolItem(ctx ToolCallContext) *DiagnosticsToolItem {
+ return &DiagnosticsToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *DiagnosticsToolItem) Render(width int) string {
+ args := NewParamBuilder().Main("project").Build()
+ header := renderToolHeader(&m.ctx, "Diagnostics", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// ReferencesToolItem renders LSP references search results.
+type ReferencesToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewReferencesToolItem(ctx ToolCallContext) *ReferencesToolItem {
+ return &ReferencesToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *ReferencesToolItem) Render(width int) string {
+ var params tools.ReferencesParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ args := NewParamBuilder().
+ Main(params.Symbol).
+ KeyValue("path", fsext.PrettyPath(params.Path)).
+ Build()
+
+ header := renderToolHeader(&m.ctx, "References", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Misc Tools
+// -----------------------------------------------------------------------------
+
+// TodosToolItem renders todo list management.
+type TodosToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewTodosToolItem(ctx ToolCallContext) *TodosToolItem {
+ return &TodosToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *TodosToolItem) Render(width int) string {
+ sty := m.ctx.Styles
+ var params tools.TodosParams
+ var meta tools.TodosResponseMetadata
+ var headerText string
+ var body string
+
+ // Parse params for pending state
+ if err := unmarshalParams(m.ctx.Call.Input, ¶ms); err == nil {
+ completedCount := 0
+ inProgressTask := ""
+ for _, todo := range params.Todos {
+ if todo.Status == "completed" {
+ completedCount++
+ }
+ if todo.Status == "in_progress" {
+ inProgressTask = cmp.Or(todo.ActiveForm, todo.Content)
+ }
+ }
+
+ // Default display from params
+ ratio := sty.Tool.JobAction.Render(fmt.Sprintf("%d/%d", completedCount, len(params.Todos)))
+ headerText = ratio
+ if inProgressTask != "" {
+ headerText = fmt.Sprintf("%s · %s", ratio, inProgressTask)
+ }
+
+ // If we have metadata, use it for richer display
+ if m.ctx.Result != nil && m.ctx.Result.Metadata != "" {
+ if err := unmarshalParams(m.ctx.Result.Metadata, &meta); err == nil {
+ headerText, body = m.formatTodosFromMeta(meta, width)
+ }
+ }
+ }
+
+ args := NewParamBuilder().Main(headerText).Build()
+ header := renderToolHeader(&m.ctx, "To-Do", width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ if body == "" {
+ return header
+ }
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+func (m *TodosToolItem) formatTodosFromMeta(meta tools.TodosResponseMetadata, width int) (string, string) {
+ sty := m.ctx.Styles
+ var headerText, body string
+
+ if meta.IsNew {
+ if meta.JustStarted != "" {
+ headerText = fmt.Sprintf("created %d todos, starting first", meta.Total)
+ } else {
+ headerText = fmt.Sprintf("created %d todos", meta.Total)
+ }
+ body = formatTodosList(meta.Todos, width, sty)
+ } else {
+ hasCompleted := len(meta.JustCompleted) > 0
+ hasStarted := meta.JustStarted != ""
+ allCompleted := meta.Completed == meta.Total
+
+ ratio := sty.Tool.JobAction.Render(fmt.Sprintf("%d/%d", meta.Completed, meta.Total))
+ if hasCompleted && hasStarted {
+ text := sty.Tool.JobDescription.Render(fmt.Sprintf(" · completed %d, starting next", len(meta.JustCompleted)))
+ headerText = ratio + text
+ } else if hasCompleted {
+ text := " · completed all"
+ if !allCompleted {
+ text = fmt.Sprintf(" · completed %d", len(meta.JustCompleted))
+ }
+ headerText = ratio + sty.Tool.JobDescription.Render(text)
+ } else if hasStarted {
+ headerText = ratio + sty.Tool.JobDescription.Render(" · starting task")
+ } else {
+ headerText = ratio
+ }
+
+ if allCompleted {
+ body = formatTodosList(meta.Todos, width, sty)
+ } else if meta.JustStarted != "" {
+ body = sty.Tool.IconSuccess.String() + " " + sty.Base.Render(meta.JustStarted)
+ }
+ }
+
+ return headerText, body
+}
+
+// AgentToolItem renders agent task execution with nested tool calls.
+type AgentToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewAgentToolItem(ctx ToolCallContext) *AgentToolItem {
+ return &AgentToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *AgentToolItem) Render(width int) string {
+ var params agent.AgentParams
+ unmarshalParams(m.ctx.Call.Input, ¶ms)
+
+ header := renderToolHeader(&m.ctx, "Agent", width)
+ body := renderAgentBody(&m.ctx, params.Prompt, "Task", header, width)
+ return body
+}
+
+// renderAgentBody renders agent/agentic_fetch body with prompt tag and nested calls tree.
+func renderAgentBody(ctx *ToolCallContext, prompt, tagLabel, header string, width int) string {
+ sty := ctx.Styles
+
+ if ctx.Cancelled {
+ if result, done := renderEarlyState(ctx, header, width); done {
+ return result
+ }
+ }
+
+ // Build prompt tag
+ prompt = strings.ReplaceAll(prompt, "\n", " ")
+ taskTag := sty.Tool.AgentTaskTag.Render(tagLabel)
+ tagWidth := lipgloss.Width(taskTag)
+ remainingWidth := min(width-tagWidth-2, 120-tagWidth-2)
+ promptStyled := sty.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
+
+ headerWithPrompt := lipgloss.JoinVertical(
+ lipgloss.Left,
+ header,
+ "",
+ lipgloss.JoinHorizontal(lipgloss.Left, taskTag, " ", promptStyled),
+ )
+
+ // Build tree with nested tool calls
+ childTools := tree.Root(headerWithPrompt)
+ for _, nestedCtx := range ctx.NestedCalls {
+ nestedCtx.IsNested = true
+ nestedItem := NewToolItem(nestedCtx)
+ childTools.Child(nestedItem.Render(remainingWidth))
+ }
+
+ parts := []string{
+ childTools.Enumerator(roundedEnumerator(2, tagWidth-5)).String(),
+ }
+
+ // Add pending indicator if not complete
+ if !ctx.HasResult() {
+ parts = append(parts, "", sty.Tool.StateWaiting.Render("Working..."))
+ }
+
+ treeOutput := lipgloss.JoinVertical(lipgloss.Left, parts...)
+
+ if !ctx.HasResult() {
+ return treeOutput
+ }
+
+ body := renderMarkdownContent(ctx.Result.Content, width-2, sty, nil)
+ return joinHeaderBody(treeOutput, body, sty)
+}
+
+// roundedEnumerator creates a tree enumerator with rounded connectors.
+func roundedEnumerator(lPadding, lineWidth int) tree.Enumerator {
+ if lineWidth == 0 {
+ lineWidth = 2
+ }
+ if lPadding == 0 {
+ lPadding = 1
+ }
+ return func(children tree.Children, index int) string {
+ line := strings.Repeat("─", lineWidth)
+ padding := strings.Repeat(" ", lPadding)
+ if children.Length()-1 == index {
+ return padding + "╰" + line
+ }
+ return padding + "├" + line
+ }
+}
+
+// GenericToolItem renders unknown tool types with basic parameter display.
+type GenericToolItem struct {
+ toolItem
+ ctx ToolCallContext
+}
+
+func NewGenericToolItem(ctx ToolCallContext) *GenericToolItem {
+ return &GenericToolItem{
+ toolItem: newToolItem(ctx),
+ ctx: ctx,
+ }
+}
+
+func (m *GenericToolItem) Render(width int) string {
+ name := prettifyToolName(m.ctx.Call.Name)
+
+ // Handle media content
+ if m.ctx.Result != nil && m.ctx.Result.Data != "" {
+ if strings.HasPrefix(m.ctx.Result.MIMEType, "image/") {
+ args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+ header := renderToolHeader(&m.ctx, name, width, args...)
+ body := renderImageContent(m.ctx.Result.Data, m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+ }
+ args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+ header := renderToolHeader(&m.ctx, name, width, args...)
+ body := renderMediaContent(m.ctx.Result.MIMEType, m.ctx.Result.Content, m.ctx.Styles)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+ }
+
+ args := NewParamBuilder().Main(m.ctx.Call.Input).Build()
+ header := renderToolHeader(&m.ctx, name, width, args...)
+
+ if result, done := renderEarlyState(&m.ctx, header, width); done {
+ return result
+ }
+
+ if m.ctx.Result == nil || m.ctx.Result.Content == "" {
+ return header
+ }
+
+ body := renderPlainContent(m.ctx.Result.Content, width-2, m.ctx.Styles, &m.toolItem)
+ return joinHeaderBody(header, body, m.ctx.Styles)
+}
+
+// -----------------------------------------------------------------------------
+// Helper Functions
+// -----------------------------------------------------------------------------
+
+// prettifyToolName converts tool names to display-friendly format.
+func prettifyToolName(name string) string {
+ switch name {
+ case agent.AgentToolName:
+ return "Agent"
+ case tools.BashToolName:
+ return "Bash"
+ case tools.JobOutputToolName:
+ return "Job: Output"
+ case tools.JobKillToolName:
+ return "Job: Kill"
+ case tools.DownloadToolName:
+ return "Download"
+ case tools.EditToolName:
+ return "Edit"
+ case tools.MultiEditToolName:
+ return "Multi-Edit"
+ case tools.FetchToolName:
+ return "Fetch"
+ case tools.AgenticFetchToolName:
+ return "Agentic Fetch"
+ case tools.WebFetchToolName:
+ return "Fetch"
+ case tools.WebSearchToolName:
+ return "Search"
+ case tools.GlobToolName:
+ return "Glob"
+ case tools.GrepToolName:
+ return "Grep"
+ case tools.LSToolName:
+ return "List"
+ case tools.SourcegraphToolName:
+ return "Sourcegraph"
+ case tools.TodosToolName:
+ return "To-Do"
+ case tools.ViewToolName:
+ return "View"
+ case tools.WriteToolName:
+ return "Write"
+ case tools.DiagnosticsToolName:
+ return "Diagnostics"
+ case tools.ReferencesToolName:
+ return "References"
+ default:
+ // Handle MCP tools and others
+ name = strings.TrimPrefix(name, "mcp_")
+ if name == "" {
+ return "Tool"
+ }
+ return strings.ToUpper(name[:1]) + name[1:]
+ }
+}
+
+// formatTimeout converts timeout seconds to duration string.
+func formatTimeout(timeout int) string {
+ if timeout == 0 {
+ return ""
+ }
+ return (time.Duration(timeout) * time.Second).String()
+}
+
+// truncateText truncates text to fit within width with ellipsis.
+func truncateText(s string, width int) string {
+ if lipgloss.Width(s) <= width {
+ return s
+ }
+ for i := len(s) - 1; i >= 0; i-- {
+ truncated := s[:i] + "…"
+ if lipgloss.Width(truncated) <= width {
+ return truncated
+ }
+ }
+ return "…"
+}
@@ -0,0 +1,111 @@
+package chat
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "charm.land/lipgloss/v2"
+ "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"
+)
+
+type UserMessageItem struct {
+ id string
+ content string
+ attachments []message.BinaryContent
+ sty *styles.Styles
+}
+
+func NewUserMessage(id, content string, attachments []message.BinaryContent, sty *styles.Styles) *UserMessageItem {
+ return &UserMessageItem{
+ id: id,
+ content: content,
+ attachments: attachments,
+ sty: sty,
+ }
+}
+
+// ID implements Identifiable.
+func (m *UserMessageItem) ID() string {
+ return m.id
+}
+
+// FocusStyle returns the focus style.
+func (m *UserMessageItem) FocusStyle() lipgloss.Style {
+ return m.sty.Chat.Message.UserFocused
+}
+
+// BlurStyle returns the blur style.
+func (m *UserMessageItem) BlurStyle() lipgloss.Style {
+ return m.sty.Chat.Message.UserBlurred
+}
+
+// HighlightStyle returns the highlight style.
+func (m *UserMessageItem) HighlightStyle() lipgloss.Style {
+ return m.sty.TextSelection
+}
+
+// Render implements MessageItem.
+func (m *UserMessageItem) Render(width int) string {
+ cappedWidth := min(width, maxTextWidth)
+ renderer := common.MarkdownRenderer(m.sty, cappedWidth)
+ result, err := renderer.Render(m.content)
+ var rendered string
+ if err != nil {
+ rendered = m.content
+ } else {
+ rendered = strings.TrimSuffix(result, "\n")
+ }
+
+ if len(m.attachments) > 0 {
+ attachmentsStr := m.renderAttachments(cappedWidth)
+ rendered = strings.Join([]string{rendered, "", attachmentsStr}, "\n")
+ }
+ return rendered
+}
+
+// renderAttachments renders attachments with wrapping if they exceed the width.
+func (m *UserMessageItem) renderAttachments(width int) string {
+ const maxFilenameWidth = 10
+
+ attachments := make([]string, len(m.attachments))
+ for i, attachment := range m.attachments {
+ filename := filepath.Base(attachment.Path)
+ attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
+ " %s %s ",
+ styles.DocumentIcon,
+ ansi.Truncate(filename, maxFilenameWidth, "..."),
+ ))
+ }
+
+ // Wrap attachments into lines that fit within the width.
+ var lines []string
+ var currentLine []string
+ currentWidth := 0
+
+ for _, att := range attachments {
+ attWidth := lipgloss.Width(att)
+ sepWidth := 1
+ if len(currentLine) == 0 {
+ sepWidth = 0
+ }
+
+ if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 {
+ lines = append(lines, strings.Join(currentLine, " "))
+ currentLine = []string{att}
+ currentWidth = attWidth
+ } else {
+ currentLine = append(currentLine, att)
+ currentWidth += sepWidth + attWidth
+ }
+ }
+
+ if len(currentLine) > 0 {
+ lines = append(lines, strings.Join(currentLine, " "))
+ }
+
+ return strings.Join(lines, "\n")
+}
@@ -1,9 +1,8 @@
package common
import (
- "github.com/charmbracelet/crush/internal/ui/styles"
"charm.land/glamour/v2"
- gstyles "charm.land/glamour/v2/styles"
+ "github.com/charmbracelet/crush/internal/ui/styles"
)
// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
@@ -16,11 +15,11 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
return r
}
-// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
-// (plain text with structure) and the given width.
-func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
+// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with muted
+// colors on a subtle background, for thinking content.
+func PlainMarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(gstyles.ASCIIStyleConfig),
+ glamour.WithStyles(t.PlainMarkdown),
glamour.WithWordWrap(width),
)
return r
@@ -1,6 +1,7 @@
package list
import (
+ tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -39,3 +40,10 @@ type FocusAware interface {
// SetFocused is called before Render to inform the item of its focus state.
SetFocused(focused bool)
}
+
+// KeyPressable represents an item that can handle key press events.
+type KeyPressable interface {
+ // HandleKeyPress processes a key press event.
+ // It returns true if the event was handled, false otherwise.
+ HandleKeyPress(msg tea.KeyPressMsg) bool
+}
@@ -4,6 +4,7 @@ import (
"image"
"strings"
+ tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
)
@@ -681,6 +682,24 @@ func (l *List) ClearHighlight() {
l.lastHighlighted = make(map[int]bool)
}
+// HandleKeyPress handles key press events for the currently selected item.
+// Returns true if the event was handled.
+func (l *List) HandleKeyPress(msg tea.KeyPressMsg) bool {
+ if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
+ return false
+ }
+
+ if keyable, ok := l.items[l.selectedIdx].(KeyPressable); ok {
+ handled := keyable.HandleKeyPress(msg)
+ if handled {
+ l.invalidateItem(l.selectedIdx)
+ }
+ return handled
+ }
+
+ return false
+}
+
// findItemAtY finds the item at the given viewport y coordinate.
// Returns the item index and the y offset within that item. It returns -1, -1
// if no item is found.
@@ -1,548 +0,0 @@
-package model
-
-import (
- "fmt"
- "path/filepath"
- "strings"
- "time"
-
- "charm.land/lipgloss/v2"
- "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.Item
- Identifiable
-}
-
-// MessageContentItem represents rendered message content (text, markdown, errors, etc).
-type MessageContentItem struct {
- id string
- content string
- role message.MessageRole
- isMarkdown bool
- maxWidth int
- sty *styles.Styles
-}
-
-// NewMessageContentItem creates a new message content item.
-func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem {
- m := &MessageContentItem{
- id: id,
- content: content,
- isMarkdown: isMarkdown,
- role: role,
- maxWidth: 120,
- sty: sty,
- }
- return m
-}
-
-// ID implements Identifiable.
-func (m *MessageContentItem) ID() string {
- return m.id
-}
-
-// FocusStyle returns the focus style.
-func (m *MessageContentItem) FocusStyle() lipgloss.Style {
- if m.role == message.User {
- return m.sty.Chat.Message.UserFocused
- }
- return m.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (m *MessageContentItem) BlurStyle() lipgloss.Style {
- if m.role == message.User {
- return m.sty.Chat.Message.UserBlurred
- }
- return m.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (m *MessageContentItem) HighlightStyle() lipgloss.Style {
- return m.sty.TextSelection
-}
-
-// Render renders the content at the given width, using cache if available.
-//
-// It implements [list.Item].
-func (m *MessageContentItem) Render(width int) string {
- contentWidth := width
- // Cap width to maxWidth for markdown
- cappedWidth := contentWidth
- if m.isMarkdown {
- cappedWidth = min(contentWidth, m.maxWidth)
- }
-
- 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
- }
-
- return rendered
-}
-
-// ToolCallItem represents a rendered tool call with its header and content.
-type ToolCallItem struct {
- id string
- toolCall message.ToolCall
- toolResult message.ToolResult
- cancelled bool
- isNested bool
- maxWidth int
- 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,
- sty: sty,
- }
- 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
-}
-
-// FocusStyle returns the focus style.
-func (t *ToolCallItem) FocusStyle() lipgloss.Style {
- return t.sty.Chat.Message.ToolCallFocused
-}
-
-// BlurStyle returns the blur style.
-func (t *ToolCallItem) BlurStyle() lipgloss.Style {
- return t.sty.Chat.Message.ToolCallBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (t *ToolCallItem) HighlightStyle() lipgloss.Style {
- return t.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (t *ToolCallItem) Render(width int) string {
- // Render the tool call
- ctx := &toolrender.RenderContext{
- Call: t.toolCall,
- Result: t.toolResult,
- Cancelled: t.cancelled,
- IsNested: t.isNested,
- Width: width,
- Styles: t.sty,
- }
-
- rendered := toolrender.Render(ctx)
- return rendered
-}
-
-// AttachmentItem represents a file attachment in a user message.
-type AttachmentItem struct {
- 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,
- }
- return a
-}
-
-// ID implements Identifiable.
-func (a *AttachmentItem) ID() string {
- return a.id
-}
-
-// FocusStyle returns the focus style.
-func (a *AttachmentItem) FocusStyle() lipgloss.Style {
- return a.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (a *AttachmentItem) BlurStyle() lipgloss.Style {
- return a.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (a *AttachmentItem) HighlightStyle() lipgloss.Style {
- return a.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (a *AttachmentItem) Render(width int) string {
- const maxFilenameWidth = 10
- content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf(
- " %s %s ",
- styles.DocumentIcon,
- ansi.Truncate(a.filename, maxFilenameWidth, "..."),
- ))
-
- return content
-
- // return a.RenderWithHighlight(content, width, a.CurrentStyle())
-}
-
-// ThinkingItem represents thinking/reasoning content in assistant messages.
-type ThinkingItem struct {
- id string
- thinking string
- duration time.Duration
- finished bool
- maxWidth int
- 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,
- sty: sty,
- }
- return t
-}
-
-// ID implements Identifiable.
-func (t *ThinkingItem) ID() string {
- return t.id
-}
-
-// FocusStyle returns the focus style.
-func (t *ThinkingItem) FocusStyle() lipgloss.Style {
- return t.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (t *ThinkingItem) BlurStyle() lipgloss.Style {
- return t.sty.Chat.Message.AssistantBlurred
-}
-
-// HighlightStyle returns the highlight style.
-func (t *ThinkingItem) HighlightStyle() lipgloss.Style {
- return t.sty.TextSelection
-}
-
-// Render implements list.Item.
-func (t *ThinkingItem) Render(width int) string {
- cappedWidth := min(width, t.maxWidth)
-
- 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)
-
- return result
-}
-
-// SectionHeaderItem represents a section header (e.g., assistant info).
-type SectionHeaderItem struct {
- id string
- modelName string
- duration time.Duration
- isSectionHeader bool
- sty *styles.Styles
- content string
-}
-
-// 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,
- }
- 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
-}
-
-// FocusStyle returns the focus style.
-func (s *SectionHeaderItem) FocusStyle() lipgloss.Style {
- return s.sty.Chat.Message.AssistantFocused
-}
-
-// BlurStyle returns the blur style.
-func (s *SectionHeaderItem) BlurStyle() lipgloss.Style {
- return s.sty.Chat.Message.AssistantBlurred
-}
-
-// Render implements list.Item.
-func (s *SectionHeaderItem) Render(width int) string {
- content := fmt.Sprintf("%s %s %s",
- s.sty.Subtle.Render(styles.ModelIcon),
- s.sty.Muted.Render(s.modelName),
- s.sty.Subtle.Render(s.duration.String()),
- )
-
- return s.sty.Chat.Message.SectionHeader.Render(content)
-}
-
-// 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(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
- var items []MessageItem
-
- // Skip tool result messages - they're displayed inline with tool calls
- if msg.Role == message.Tool {
- return items
- }
-
- // 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,
- msg.Role,
- true, // User messages are markdown
- sty,
- )
- 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,
- )
- 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,
- )
- 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*",
- msg.Role,
- true,
- sty,
- )
- 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,
- msg.Role,
- false,
- sty,
- )
- items = append(items, item)
- }
- } else if content != "" {
- item := NewMessageContentItem(
- fmt.Sprintf("%s-content", msg.ID),
- content,
- msg.Role,
- true, // Assistant messages are markdown
- sty,
- )
- 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,
- )
-
- 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
-}
@@ -7,12 +7,15 @@ import (
"os"
"slices"
"strings"
+ "time"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/agent"
+ "github.com/charmbracelet/crush/internal/agent/tools"
"github.com/charmbracelet/crush/internal/agent/tools/mcp"
"github.com/charmbracelet/crush/internal/app"
"github.com/charmbracelet/crush/internal/config"
@@ -20,6 +23,7 @@ import (
"github.com/charmbracelet/crush/internal/message"
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
+ "github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/dialog"
"github.com/charmbracelet/crush/internal/ui/logo"
@@ -103,7 +107,7 @@ type UI struct {
workingPlaceholder string
// Chat components
- chat *Chat
+ chat *chat.Chat
// onboarding state
onboarding struct {
@@ -130,7 +134,7 @@ func New(com *common.Common) *UI {
ta.SetVirtualCursor(false)
ta.Focus()
- ch := NewChat(com)
+ ch := chat.NewChat(com)
ui := &UI{
com: com,
@@ -201,23 +205,10 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case sessionLoadedMsg:
m.state = uiChat
m.session = &msg.sess
- // Load the last 20 messages from this session.
+ // TODO: handle error.
msgs, _ := m.com.App.Messages.List(context.Background(), m.session.ID)
- // 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(m.com.Styles, msg, toolResultMap)...)
- }
-
- m.chat.SetMessages(items...)
+ m.chat.SetMessages(m.convertChatMessages(msgs)...)
// 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
@@ -407,43 +398,47 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) (cmds []tea.Cmd) {
m.chat.Focus()
m.chat.SetSelected(m.chat.Len() - 1)
}
- case key.Matches(msg, m.keyMap.Chat.Up):
+ case key.Matches(msg, m.keyMap.Chat.Up) && m.focus == uiFocusMain:
m.chat.ScrollBy(-1)
if !m.chat.SelectedItemInView() {
m.chat.SelectPrev()
m.chat.ScrollToSelected()
}
- case key.Matches(msg, m.keyMap.Chat.Down):
+ case key.Matches(msg, m.keyMap.Chat.Down) && m.focus == uiFocusMain:
m.chat.ScrollBy(1)
if !m.chat.SelectedItemInView() {
m.chat.SelectNext()
m.chat.ScrollToSelected()
}
- case key.Matches(msg, m.keyMap.Chat.UpOneItem):
+ case key.Matches(msg, m.keyMap.Chat.UpOneItem) && m.focus == uiFocusMain:
m.chat.SelectPrev()
m.chat.ScrollToSelected()
- case key.Matches(msg, m.keyMap.Chat.DownOneItem):
+ case key.Matches(msg, m.keyMap.Chat.DownOneItem) && m.focus == uiFocusMain:
m.chat.SelectNext()
m.chat.ScrollToSelected()
- case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
+ case key.Matches(msg, m.keyMap.Chat.HalfPageUp) && m.focus == uiFocusMain:
m.chat.ScrollBy(-m.chat.Height() / 2)
m.chat.SelectFirstInView()
- case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
+ case key.Matches(msg, m.keyMap.Chat.HalfPageDown) && m.focus == uiFocusMain:
m.chat.ScrollBy(m.chat.Height() / 2)
m.chat.SelectLastInView()
- case key.Matches(msg, m.keyMap.Chat.PageUp):
+ case key.Matches(msg, m.keyMap.Chat.PageUp) && m.focus == uiFocusMain:
m.chat.ScrollBy(-m.chat.Height())
m.chat.SelectFirstInView()
- case key.Matches(msg, m.keyMap.Chat.PageDown):
+ case key.Matches(msg, m.keyMap.Chat.PageDown) && m.focus == uiFocusMain:
m.chat.ScrollBy(m.chat.Height())
m.chat.SelectLastInView()
- case key.Matches(msg, m.keyMap.Chat.Home):
+ case key.Matches(msg, m.keyMap.Chat.Home) && m.focus == uiFocusMain:
m.chat.ScrollToTop()
m.chat.SelectFirst()
- case key.Matches(msg, m.keyMap.Chat.End):
+ case key.Matches(msg, m.keyMap.Chat.End) && m.focus == uiFocusMain:
m.chat.ScrollToBottom()
m.chat.SelectLast()
default:
+ // Try to handle key press in focused item (for expansion, etc.)
+ if m.focus == uiFocusMain && m.chat.HandleKeyPress(msg) {
+ return cmds
+ }
handleGlobalKeys(msg)
}
default:
@@ -976,6 +971,150 @@ func (m *UI) loadSessionsCmd() tea.Msg {
return sessionsLoadedMsg{sessions: allSessions}
}
+// convertChatMessages converts messages to chat message items
+func (m *UI) convertChatMessages(msgs []message.Message) []chat.MessageItem {
+ items := make([]chat.MessageItem, 0)
+
+ // Build tool result map for efficient lookup.
+ toolResultMap := m.buildToolResultMap(msgs)
+
+ var lastUserMessageTime time.Time
+
+ for _, msg := range msgs {
+ switch msg.Role {
+ case message.User:
+ lastUserMessageTime = time.Unix(msg.CreatedAt, 0)
+ items = append(items, chat.NewUserMessage(msg.ID, msg.Content().Text, msg.BinaryContent(), m.com.Styles))
+ case message.Assistant:
+ // Add assistant message and its tool calls.
+ assistantItems := m.convertAssistantMessage(msg, toolResultMap)
+ items = append(items, assistantItems...)
+
+ // Add section separator if assistant finished with EndTurn.
+ if msg.FinishReason() == message.FinishReasonEndTurn {
+ modelName := m.getModelName(msg)
+ items = append(items, chat.NewSectionItem(msg, lastUserMessageTime, modelName, m.com.Styles))
+ }
+ }
+ }
+ return items
+}
+
+// getModelName returns the display name for a model, or "Unknown Model" if not found.
+func (m *UI) getModelName(msg message.Message) string {
+ model := m.com.Config().GetModel(msg.Provider, msg.Model)
+ if model == nil {
+ return "Unknown Model"
+ }
+ return model.Name
+}
+
+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
+func (m *UI) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
+ toolResultMap := make(map[string]message.ToolResult)
+ for _, msg := range messages {
+ for _, tr := range msg.ToolResults() {
+ toolResultMap[tr.ToolCallID] = tr
+ }
+ }
+ return toolResultMap
+}
+
+// convertAssistantMessage converts an assistant message and its tool calls to UI items.
+func (m *UI) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []chat.MessageItem {
+ var items []chat.MessageItem
+
+ // Add assistant text/thinking message if it has content.
+ content := strings.TrimSpace(msg.Content().Text)
+ thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+ isError := msg.FinishReason() == message.FinishReasonError
+ isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+
+ // Show assistant message if there's content, thinking, or status to display.
+ if content != "" || thinking != "" || isError || isCancelled {
+ var finish message.Finish
+ if fp := msg.FinishPart(); fp != nil {
+ finish = *fp
+ }
+
+ items = append(items, chat.NewAssistantMessage(
+ msg.ID,
+ content,
+ thinking,
+ msg.IsFinished(),
+ finish,
+ m.com.Styles,
+ ))
+ }
+
+ // Add tool call items.
+ for _, tc := range msg.ToolCalls() {
+ ctx := m.buildToolCallContext(tc, msg, toolResultMap)
+
+ // Handle nested tool calls for agent/agentic_fetch.
+ if tc.Name == agent.AgentToolName || tc.Name == tools.AgenticFetchToolName {
+ ctx.NestedCalls = m.loadNestedToolCalls(msg.ID, tc.ID)
+ }
+
+ items = append(items, chat.NewToolItem(ctx))
+ }
+
+ return items
+}
+
+// buildToolCallContext creates a ToolCallContext from a tool call and its result.
+func (m *UI) buildToolCallContext(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) chat.ToolCallContext {
+ ctx := chat.ToolCallContext{
+ Call: tc,
+ Styles: m.com.Styles,
+ IsNested: false,
+ Cancelled: msg.FinishReason() == message.FinishReasonCanceled,
+ }
+
+ // Add tool result if available.
+ if tr, ok := toolResultMap[tc.ID]; ok {
+ ctx.Result = &tr
+ }
+
+ // TODO: Add permission tracking when we have permission service.
+ // ctx.PermissionRequested = ...
+ // ctx.PermissionGranted = ...
+
+ return ctx
+}
+
+// loadNestedToolCalls loads nested tool calls for agent/agentic_fetch tools.
+func (m *UI) loadNestedToolCalls(msgID, toolCallID string) []chat.ToolCallContext {
+ agentSessionID := m.com.App.Sessions.CreateAgentToolSessionID(msgID, toolCallID)
+ nestedMsgs, err := m.com.App.Messages.List(context.Background(), agentSessionID)
+ if err != nil || len(nestedMsgs) == 0 {
+ return nil
+ }
+
+ // Build nested tool result map.
+ nestedToolResultMap := m.buildToolResultMap(nestedMsgs)
+
+ var nestedContexts []chat.ToolCallContext
+ for _, nestedMsg := range nestedMsgs {
+ for _, nestedTC := range nestedMsg.ToolCalls() {
+ ctx := chat.ToolCallContext{
+ Call: nestedTC,
+ Styles: m.com.Styles,
+ IsNested: true,
+ Cancelled: nestedMsg.FinishReason() == message.FinishReasonCanceled,
+ }
+
+ if tr, ok := nestedToolResultMap[nestedTC.ID]; ok {
+ ctx.Result = &tr
+ }
+
+ nestedContexts = append(nestedContexts, ctx)
+ }
+ }
+
+ return nestedContexts
+}
+
// renderLogo renders the Crush logo with the given styles and dimensions.
func renderLogo(t *styles.Styles, compact bool, width int) string {
return logo.Render(version.Version, compact, logo.Opts{
@@ -8,10 +8,10 @@ import (
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
+ "charm.land/glamour/v2/ansi"
"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"
)
@@ -26,14 +26,25 @@ const (
DocumentIcon string = "🖼"
ModelIcon string = "◇"
+ // Arrow icons
+ ArrowRightIcon string = "→"
+
+ // Tool call icons
ToolPending string = "●"
ToolSuccess string = "✓"
ToolError string = "×"
+ // Border styles
BorderThin string = "│"
BorderThick string = "▌"
+ // Section separator
SectionSeparator string = "─"
+
+ // Todo icons
+ TodoCompletedIcon string = "✓"
+ TodoPendingIcon string = "•"
+ TodoInProgressIcon string = "→"
)
const (
@@ -85,7 +96,8 @@ type Styles struct {
ItemOnlineIcon lipgloss.Style
// Markdown & Chroma
- Markdown ansi.StyleConfig
+ Markdown ansi.StyleConfig
+ PlainMarkdown ansi.StyleConfig
// Inputs
TextInput textinput.Styles
@@ -197,6 +209,11 @@ type Styles struct {
ToolCallBlurred lipgloss.Style
ThinkingFooter lipgloss.Style
SectionHeader lipgloss.Style
+
+ // Section styles - for assistant response metadata
+ SectionIcon lipgloss.Style // Model icon
+ SectionModel lipgloss.Style // Model name
+ SectionDuration lipgloss.Style // Response duration
}
}
@@ -661,6 +678,169 @@ func DefaultStyles() Styles {
},
}
+ // PlainMarkdown style - muted colors on subtle background for thinking content.
+ plainBg := stringPtr(bgBaseLighter.Hex())
+ plainFg := stringPtr(fgMuted.Hex())
+ s.PlainMarkdown = ansi.StyleConfig{
+ Document: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ BlockQuote: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Indent: uintPtr(1),
+ IndentToken: stringPtr("│ "),
+ },
+ List: ansi.StyleList{
+ LevelIndent: defaultListIndent,
+ },
+ Heading: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ BlockSuffix: "\n",
+ Bold: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H1: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Suffix: " ",
+ Bold: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H2: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "## ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H3: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "### ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H4: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "#### ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H5: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "##### ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ H6: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: "###### ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ Strikethrough: ansi.StylePrimitive{
+ CrossedOut: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Emph: ansi.StylePrimitive{
+ Italic: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Strong: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ HorizontalRule: ansi.StylePrimitive{
+ Format: "\n--------\n",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Item: ansi.StylePrimitive{
+ BlockPrefix: "• ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Enumeration: ansi.StylePrimitive{
+ BlockPrefix: ". ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Task: ansi.StyleTask{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Ticked: "[✓] ",
+ Unticked: "[ ] ",
+ },
+ Link: ansi.StylePrimitive{
+ Underline: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ LinkText: ansi.StylePrimitive{
+ Bold: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Image: ansi.StylePrimitive{
+ Underline: boolPtr(true),
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ ImageText: ansi.StylePrimitive{
+ Format: "Image: {{.text}} →",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Code: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Prefix: " ",
+ Suffix: " ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ CodeBlock: ansi.StyleCodeBlock{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ Margin: uintPtr(defaultMargin),
+ },
+ },
+ Table: ansi.StyleTable{
+ StyleBlock: ansi.StyleBlock{
+ StylePrimitive: ansi.StylePrimitive{
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ },
+ },
+ DefinitionDescription: ansi.StylePrimitive{
+ BlockPrefix: "\n ",
+ Color: plainFg,
+ BackgroundColor: plainBg,
+ },
+ }
+
s.Help = help.Styles{
ShortKey: base.Foreground(fgMuted),
ShortDesc: base.Foreground(fgSubtle),
@@ -779,7 +959,7 @@ func DefaultStyles() Styles {
// 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.ContentCodeLine = s.Base.Background(bgBase)
s.Tool.ContentCodeBg = bgBase
s.Tool.BodyPadding = base.PaddingLeft(2)
@@ -796,7 +976,7 @@ func DefaultStyles() Styles {
// 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.NoteTag = base.Padding(0, 2).Background(info).Foreground(white)
s.Tool.NoteMessage = base.Foreground(fgHalfMuted)
// Job header styles
@@ -880,7 +1060,7 @@ func DefaultStyles() Styles {
s.Chat.Message.ErrorDetails = lipgloss.NewStyle().Foreground(fgSubtle)
// Message item styles
- s.Chat.Message.Attachment = lipgloss.NewStyle().MarginLeft(1).Background(bgSubtle)
+ s.Chat.Message.Attachment = lipgloss.NewStyle().Background(bgSubtle)
s.Chat.Message.ToolCallFocused = s.Muted.PaddingLeft(1).
BorderStyle(messageFocussedBorder).
BorderLeft(true).
@@ -889,6 +1069,11 @@ func DefaultStyles() Styles {
s.Chat.Message.ThinkingFooter = s.Base
s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
+ // Section metadata styles
+ s.Chat.Message.SectionIcon = s.Subtle
+ s.Chat.Message.SectionModel = s.Muted
+ s.Chat.Message.SectionDuration = s.Subtle
+
// Text selection.
s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
@@ -1,889 +0,0 @@
-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) + " "
- }
-}