diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go new file mode 100644 index 0000000000000000000000000000000000000000..120864dd9761fbb4cb988dfb4af39d29dbb00c68 --- /dev/null +++ b/internal/ui/chat/assistant.go @@ -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) +} diff --git a/internal/ui/model/chat.go b/internal/ui/chat/chat.go similarity index 84% rename from internal/ui/model/chat.go rename to internal/ui/chat/chat.go index 562bd2cc79824c5801b57d11d9570344c3a39317..597144e3d25cea08dd9056fd4f6ba85ba54344a9 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/chat/chat.go @@ -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) +} diff --git a/internal/ui/chat/section.go b/internal/ui/chat/section.go new file mode 100644 index 0000000000000000000000000000000000000000..3522b621ce6577d588bd0ff5a406dfc5954b1670 --- /dev/null +++ b/internal/ui/chat/section.go @@ -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) +} diff --git a/internal/ui/chat/tool_base.go b/internal/ui/chat/tool_base.go new file mode 100644 index 0000000000000000000000000000000000000000..48e3a003c1f4fd4c712e95acc61c4c33d8964a87 --- /dev/null +++ b/internal/ui/chat/tool_base.go @@ -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 + } +} diff --git a/internal/ui/chat/tool_items.go b/internal/ui/chat/tool_items.go new file mode 100644 index 0000000000000000000000000000000000000000..3c4405e1d1cc02d8f9a5a6176a107ba339665080 --- /dev/null +++ b/internal/ui/chat/tool_items.go @@ -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 "…" +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go new file mode 100644 index 0000000000000000000000000000000000000000..799423db1cec4778ad1a6d60d4cf0b79072d9285 --- /dev/null +++ b/internal/ui/chat/user.go @@ -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") +} diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go index a92e70c689530daa36a6147d1c52d2875e556a52..59942c65c03d195bd438543fdff4a3c738dd59f5 100644 --- a/internal/ui/common/markdown.go +++ b/internal/ui/common/markdown.go @@ -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 diff --git a/internal/ui/list/item.go b/internal/ui/list/item.go index 279c65b03daf7f9b306bca6d16107957211d7c8a..f9aeffd488773b212d64aef1d981452f23ba6ccf 100644 --- a/internal/ui/list/item.go +++ b/internal/ui/list/item.go @@ -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 +} diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index d33b3669fd3e70a966101a4e1a5240a5e197e32a..3bf3bd06fdf21ea38f19d4151730b6abbbccbb57 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -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. diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go deleted file mode 100644 index 789208df297fa4ab5ddeaa25651a8ac66ef5812a..0000000000000000000000000000000000000000 --- a/internal/ui/model/items.go +++ /dev/null @@ -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 -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 9fe351283decaeecfedec63ff88c8ddbc5e98b4a..70edceb6e715aa99e686faaa9fd69a776fe89505 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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{ diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index cd9df36c8a20713649bdade5405db8ecc855c221..b3d902d4e65b61242a17c05085282722eb272157 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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) diff --git a/internal/ui/toolrender/render.go b/internal/ui/toolrender/render.go deleted file mode 100644 index 18895908583322a06e58de553a6291ba9f7b3448..0000000000000000000000000000000000000000 --- a/internal/ui/toolrender/render.go +++ /dev/null @@ -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) + " " - } -}