cspell.json 🔗
@@ -1 +1 @@
Kujtim Hoxha created
cspell.json | 0
internal/ui/anim/anim.go | 445 +++++++++++++++++
internal/ui/chat/assistant.go | 227 ++++++++
internal/ui/chat/messages.go | 45 +
internal/ui/chat/user.go | 10
internal/ui/common/markdown.go | 9
internal/ui/model/chat.go | 46 +
internal/ui/model/ui.go | 86 ++
internal/ui/styles/styles.go | 182 ++++++
internal/ui/toolrender/render.go | 889 ----------------------------------
10 files changed, 1,005 insertions(+), 934 deletions(-)
@@ -1 +1 @@
@@ -0,0 +1,445 @@
+// Package anim provides an animated spinner.
+package anim
+
+import (
+ "fmt"
+ "image/color"
+ "math/rand/v2"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/zeebo/xxh3"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/lucasb-eyer/go-colorful"
+
+ "github.com/charmbracelet/crush/internal/csync"
+)
+
+const (
+ fps = 20
+ initialChar = '.'
+ labelGap = " "
+ labelGapWidth = 1
+
+ // Periods of ellipsis animation speed in steps.
+ //
+ // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
+ // change every 8 frames (400 milliseconds).
+ ellipsisAnimSpeed = 8
+
+ // The maximum amount of time that can pass before a character appears.
+ // This is used to create a staggered entrance effect.
+ maxBirthOffset = time.Second
+
+ // Number of frames to prerender for the animation. After this number
+ // of frames, the animation will loop. This only applies when color
+ // cycling is disabled.
+ prerenderedFrames = 10
+
+ // Default number of cycling chars.
+ defaultNumCyclingChars = 10
+)
+
+// Default colors for gradient.
+var (
+ defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
+ defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
+ defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
+)
+
+var (
+ availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
+ ellipsisFrames = []string{".", "..", "...", ""}
+)
+
+// Internal ID management. Used during animating to ensure that frame messages
+// are received only by spinner components that sent them.
+var lastID int64
+
+func nextID() int {
+ return int(atomic.AddInt64(&lastID, 1))
+}
+
+// Cache for expensive animation calculations
+type animCache struct {
+ initialFrames [][]string
+ cyclingFrames [][]string
+ width int
+ labelWidth int
+ label []string
+ ellipsisFrames []string
+}
+
+var animCacheMap = csync.NewMap[string, *animCache]()
+
+// settingsHash creates a hash key for the settings to use for caching
+func settingsHash(opts Settings) string {
+ h := xxh3.New()
+ fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
+ opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
+ return fmt.Sprintf("%x", h.Sum(nil))
+}
+
+// StepMsg is a message type used to trigger the next step in the animation.
+type StepMsg struct{ ID string }
+
+// Settings defines settings for the animation.
+type Settings struct {
+ ID string
+ Size int
+ Label string
+ LabelColor color.Color
+ GradColorA color.Color
+ GradColorB color.Color
+ CycleColors bool
+}
+
+// Default settings.
+const ()
+
+// Anim is a Bubble for an animated spinner.
+type Anim struct {
+ width int
+ cyclingCharWidth int
+ label *csync.Slice[string]
+ labelWidth int
+ labelColor color.Color
+ startTime time.Time
+ birthOffsets []time.Duration
+ initialFrames [][]string // frames for the initial characters
+ initialized atomic.Bool
+ cyclingFrames [][]string // frames for the cycling characters
+ step atomic.Int64 // current main frame step
+ ellipsisStep atomic.Int64 // current ellipsis frame step
+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
+ id string
+}
+
+// New creates a new Anim instance with the specified width and label.
+func New(opts Settings) *Anim {
+ a := &Anim{}
+ // Validate settings.
+ if opts.Size < 1 {
+ opts.Size = defaultNumCyclingChars
+ }
+ if colorIsUnset(opts.GradColorA) {
+ opts.GradColorA = defaultGradColorA
+ }
+ if colorIsUnset(opts.GradColorB) {
+ opts.GradColorB = defaultGradColorB
+ }
+ if colorIsUnset(opts.LabelColor) {
+ opts.LabelColor = defaultLabelColor
+ }
+
+ if opts.ID != "" {
+ a.id = opts.ID
+ } else {
+ a.id = fmt.Sprintf("%d", nextID())
+ }
+ a.startTime = time.Now()
+ a.cyclingCharWidth = opts.Size
+ a.labelColor = opts.LabelColor
+
+ // Check cache first
+ cacheKey := settingsHash(opts)
+ cached, exists := animCacheMap.Get(cacheKey)
+
+ if exists {
+ // Use cached values
+ a.width = cached.width
+ a.labelWidth = cached.labelWidth
+ a.label = csync.NewSliceFrom(cached.label)
+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
+ a.initialFrames = cached.initialFrames
+ a.cyclingFrames = cached.cyclingFrames
+ } else {
+ // Generate new values and cache them
+ a.labelWidth = lipgloss.Width(opts.Label)
+
+ // Total width of anim, in cells.
+ a.width = opts.Size
+ if opts.Label != "" {
+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
+ }
+
+ // Render the label
+ a.renderLabel(opts.Label)
+
+ // Pre-generate gradient.
+ var ramp []color.Color
+ numFrames := prerenderedFrames
+ if opts.CycleColors {
+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
+ numFrames = a.width * 2
+ } else {
+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
+ }
+
+ // Pre-render initial characters.
+ a.initialFrames = make([][]string, numFrames)
+ offset := 0
+ for i := range a.initialFrames {
+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
+ for j := range a.initialFrames[i] {
+ if j+offset >= len(ramp) {
+ continue // skip if we run out of colors
+ }
+
+ var c color.Color
+ if j <= a.cyclingCharWidth {
+ c = ramp[j+offset]
+ } else {
+ c = opts.LabelColor
+ }
+
+ // Also prerender the initial character with Lip Gloss to avoid
+ // processing in the render loop.
+ a.initialFrames[i][j] = lipgloss.NewStyle().
+ Foreground(c).
+ Render(string(initialChar))
+ }
+ if opts.CycleColors {
+ offset++
+ }
+ }
+
+ // Prerender scrambled rune frames for the animation.
+ a.cyclingFrames = make([][]string, numFrames)
+ offset = 0
+ for i := range a.cyclingFrames {
+ a.cyclingFrames[i] = make([]string, a.width)
+ for j := range a.cyclingFrames[i] {
+ if j+offset >= len(ramp) {
+ continue // skip if we run out of colors
+ }
+
+ // Also prerender the color with Lip Gloss here to avoid processing
+ // in the render loop.
+ r := availableRunes[rand.IntN(len(availableRunes))]
+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
+ Foreground(ramp[j+offset]).
+ Render(string(r))
+ }
+ if opts.CycleColors {
+ offset++
+ }
+ }
+
+ // Cache the results
+ labelSlice := make([]string, a.label.Len())
+ for i, v := range a.label.Seq2() {
+ labelSlice[i] = v
+ }
+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
+ for i, v := range a.ellipsisFrames.Seq2() {
+ ellipsisSlice[i] = v
+ }
+ cached = &animCache{
+ initialFrames: a.initialFrames,
+ cyclingFrames: a.cyclingFrames,
+ width: a.width,
+ labelWidth: a.labelWidth,
+ label: labelSlice,
+ ellipsisFrames: ellipsisSlice,
+ }
+ animCacheMap.Set(cacheKey, cached)
+ }
+
+ // Random assign a birth to each character for a stagged entrance effect.
+ a.birthOffsets = make([]time.Duration, a.width)
+ for i := range a.birthOffsets {
+ a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
+ }
+
+ return a
+}
+
+// SetLabel updates the label text and re-renders it.
+func (a *Anim) SetLabel(newLabel string) {
+ a.labelWidth = lipgloss.Width(newLabel)
+
+ // Update total width
+ a.width = a.cyclingCharWidth
+ if newLabel != "" {
+ a.width += labelGapWidth + a.labelWidth
+ }
+
+ // Re-render the label
+ a.renderLabel(newLabel)
+}
+
+// renderLabel renders the label with the current label color.
+func (a *Anim) renderLabel(label string) {
+ if a.labelWidth > 0 {
+ // Pre-render the label.
+ labelRunes := []rune(label)
+ a.label = csync.NewSlice[string]()
+ for i := range labelRunes {
+ rendered := lipgloss.NewStyle().
+ Foreground(a.labelColor).
+ Render(string(labelRunes[i]))
+ a.label.Append(rendered)
+ }
+
+ // Pre-render the ellipsis frames which come after the label.
+ a.ellipsisFrames = csync.NewSlice[string]()
+ for _, frame := range ellipsisFrames {
+ rendered := lipgloss.NewStyle().
+ Foreground(a.labelColor).
+ Render(frame)
+ a.ellipsisFrames.Append(rendered)
+ }
+ } else {
+ a.label = csync.NewSlice[string]()
+ a.ellipsisFrames = csync.NewSlice[string]()
+ }
+}
+
+// Width returns the total width of the animation.
+func (a *Anim) Width() (w int) {
+ w = a.width
+ if a.labelWidth > 0 {
+ w += labelGapWidth + a.labelWidth
+
+ var widestEllipsisFrame int
+ for _, f := range ellipsisFrames {
+ fw := lipgloss.Width(f)
+ if fw > widestEllipsisFrame {
+ widestEllipsisFrame = fw
+ }
+ }
+ w += widestEllipsisFrame
+ }
+ return w
+}
+
+// Start starts the animation.
+func (a *Anim) Start() tea.Cmd {
+ return a.Step()
+}
+
+// Animate advances the animation to the next step.
+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
+ if msg.ID != a.id {
+ return nil
+ }
+
+ step := a.step.Add(1)
+ if int(step) >= len(a.cyclingFrames) {
+ a.step.Store(0)
+ }
+
+ if a.initialized.Load() && a.labelWidth > 0 {
+ // Manage the ellipsis animation.
+ ellipsisStep := a.ellipsisStep.Add(1)
+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
+ a.ellipsisStep.Store(0)
+ }
+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
+ a.initialized.Store(true)
+ }
+ return a.Step()
+}
+
+// Render renders the current state of the animation.
+func (a *Anim) Render() string {
+ var b strings.Builder
+ step := int(a.step.Load())
+ for i := range a.width {
+ switch {
+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
+ // Birth offset not reached: render initial character.
+ b.WriteString(a.initialFrames[step][i])
+ case i < a.cyclingCharWidth:
+ // Render a cycling character.
+ b.WriteString(a.cyclingFrames[step][i])
+ case i == a.cyclingCharWidth:
+ // Render label gap.
+ b.WriteString(labelGap)
+ case i > a.cyclingCharWidth:
+ // Label.
+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
+ b.WriteString(labelChar)
+ }
+ }
+ }
+ // Render animated ellipsis at the end of the label if all characters
+ // have been initialized.
+ if a.initialized.Load() && a.labelWidth > 0 {
+ ellipsisStep := int(a.ellipsisStep.Load())
+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
+ b.WriteString(ellipsisFrame)
+ }
+ }
+
+ return b.String()
+}
+
+// Step is a command that triggers the next step in the animation.
+func (a *Anim) Step() tea.Cmd {
+ return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
+ return StepMsg{ID: a.id}
+ })
+}
+
+// makeGradientRamp() returns a slice of colors blended between the given keys.
+// Blending is done as Hcl to stay in gamut.
+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
+ if len(stops) < 2 {
+ return nil
+ }
+
+ points := make([]colorful.Color, len(stops))
+ for i, k := range stops {
+ points[i], _ = colorful.MakeColor(k)
+ }
+
+ numSegments := len(stops) - 1
+ if numSegments == 0 {
+ return nil
+ }
+ blended := make([]color.Color, 0, size)
+
+ // Calculate how many colors each segment should have.
+ segmentSizes := make([]int, numSegments)
+ baseSize := size / numSegments
+ remainder := size % numSegments
+
+ // Distribute the remainder across segments.
+ for i := range numSegments {
+ segmentSizes[i] = baseSize
+ if i < remainder {
+ segmentSizes[i]++
+ }
+ }
+
+ // Generate colors for each segment.
+ for i := range numSegments {
+ c1 := points[i]
+ c2 := points[i+1]
+ segmentSize := segmentSizes[i]
+
+ for j := range segmentSize {
+ if segmentSize == 0 {
+ continue
+ }
+ t := float64(j) / float64(segmentSize)
+ c := c1.BlendHcl(c2, t)
+ blended = append(blended, c)
+ }
+ }
+
+ return blended
+}
+
+func colorIsUnset(c color.Color) bool {
+ if c == nil {
+ return true
+ }
+ _, _, _, a := c.RGBA()
+ return a == 0
+}
@@ -0,0 +1,227 @@
+package chat
+
+import (
+ "fmt"
+ "strings"
+
+ tea "charm.land/bubbletea/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/anim"
+ "github.com/charmbracelet/crush/internal/ui/common"
+ "github.com/charmbracelet/crush/internal/ui/styles"
+ "github.com/charmbracelet/x/ansi"
+)
+
+// maxCollapsedThinkingHeight defines the maximum height of the thinking
+const maxCollapsedThinkingHeight = 10
+
+// AssistantMessageItem represents an assistant message in the chat UI.
+//
+// This item includes thinking, and the content but does not include the tool calls.
+type AssistantMessageItem struct {
+ *highlightableMessageItem
+ *cachedMessageItem
+ *focusableMessageItem
+
+ message *message.Message
+ sty *styles.Styles
+ anim *anim.Anim
+ thinkingExpanded bool
+ thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
+}
+
+// NewAssistantMessageItem creates a new AssistantMessageItem.
+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
+ a := &AssistantMessageItem{
+ highlightableMessageItem: defaultHighlighter(sty),
+ cachedMessageItem: &cachedMessageItem{},
+ focusableMessageItem: &focusableMessageItem{},
+ message: message,
+ sty: sty,
+ }
+
+ a.anim = anim.New(anim.Settings{
+ ID: a.ID(),
+ Size: 15,
+ GradColorA: sty.Primary,
+ GradColorB: sty.Secondary,
+ LabelColor: sty.FgBase,
+ CycleColors: true,
+ })
+ return a
+}
+
+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
+ if !a.isSpinning() {
+ return nil
+ }
+ return a.anim.Start()
+}
+
+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+ if !a.isSpinning() {
+ return nil
+ }
+ return a.anim.Animate(msg)
+}
+
+// ID implements MessageItem.
+func (a *AssistantMessageItem) ID() string {
+ return a.message.ID
+}
+
+// Render implements MessageItem.
+func (a *AssistantMessageItem) Render(width int) string {
+ cappedWidth := cappedMessageWidth(width)
+ style := a.sty.Chat.Message.AssistantBlurred
+ if a.focused {
+ style = a.sty.Chat.Message.AssistantFocused
+ }
+
+ var spinner string
+ if a.isSpinning() {
+ spinner = a.renderSpinning()
+ }
+
+ content, height, ok := a.getCachedRender(cappedWidth)
+ if !ok {
+ content = a.renderMessageContent(cappedWidth)
+ height = lipgloss.Height(content)
+ // cache the rendered content
+ a.setCachedRender(content, cappedWidth, height)
+ }
+
+ highlightedContent := a.renderHighlighted(content, cappedWidth, height)
+ if spinner != "" {
+ if highlightedContent != "" {
+ highlightedContent += "\n\n"
+ }
+ return style.Render(highlightedContent + spinner)
+ }
+
+ return style.Render(highlightedContent)
+}
+
+func (a *AssistantMessageItem) renderMessageContent(width int) string {
+ var messageParts []string
+ thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
+ content := strings.TrimSpace(a.message.Content().Text)
+ // if the massage has reasoning content add that first
+ if thinking != "" {
+ messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
+ }
+
+ // then add the main content
+ if content != "" {
+ // add a spacer between thinking and content
+ if thinking != "" {
+ messageParts = append(messageParts, "")
+ }
+ messageParts = append(messageParts, a.renderMarkdown(content, width))
+ }
+
+ // finally add any finish reason info
+ if a.message.IsFinished() {
+ switch a.message.FinishReason() {
+ case message.FinishReasonCanceled:
+ messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
+ case message.FinishReasonError:
+ messageParts = append(messageParts, a.renderError(width))
+ }
+ }
+
+ return strings.Join(messageParts, "\n")
+}
+
+// renderThinking renders the thinking/reasoning content with footer.
+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
+ renderer := common.PlainMarkdownRenderer(a.sty, width)
+ rendered, err := renderer.Render(thinking)
+ if err != nil {
+ rendered = thinking
+ }
+ rendered = strings.TrimSpace(rendered)
+
+ lines := strings.Split(rendered, "\n")
+ totalLines := len(lines)
+
+ isTruncated := totalLines > maxCollapsedThinkingHeight
+ if !a.thinkingExpanded && isTruncated {
+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
+ }
+
+ if !a.thinkingExpanded && isTruncated {
+ hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
+ fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
+ )
+ lines = append([]string{hint}, lines...)
+ }
+
+ thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
+ result := thinkingStyle.Render(strings.Join(lines, "\n"))
+ a.thinkingBoxHeight = lipgloss.Height(result)
+
+ var footer string
+ // if thinking is done add the thought for footer
+ if !a.message.IsThinking() {
+ duration := a.message.ThinkingDuration()
+ if duration.String() != "0s" {
+ footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
+ a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
+ }
+ }
+
+ if footer != "" {
+ result += "\n\n" + footer
+ }
+
+ return result
+}
+
+// renderMarkdown renders content as markdown.
+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
+ renderer := common.MarkdownRenderer(a.sty, width)
+ result, err := renderer.Render(content)
+ if err != nil {
+ return content
+ }
+ return strings.TrimSuffix(result, "\n")
+}
+
+func (a *AssistantMessageItem) renderSpinning() string {
+ if a.message.IsThinking() {
+ a.anim.SetLabel("Thinking")
+ } else if a.message.IsSummaryMessage {
+ a.anim.SetLabel("Summarizing")
+ }
+ return a.anim.Render()
+}
+
+// renderError renders an error message.
+func (a *AssistantMessageItem) renderError(width int) string {
+ finishPart := a.message.FinishPart()
+ errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
+ truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
+ title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
+ details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
+ return fmt.Sprintf("%s\n\n%s", title, details)
+}
+
+// isSpinning returns true if the assistant message is still generating.
+func (a *AssistantMessageItem) isSpinning() bool {
+ isThinking := a.message.IsThinking()
+ isFinished := a.message.IsFinished()
+ return isThinking || !isFinished
+}
+
+// SetMessage is used to update the underlying message.
+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
+ wasSpinning := a.isSpinning()
+ a.message = message
+ a.clearCache()
+ if !wasSpinning && a.isSpinning() {
+ return a.StartAnimation()
+ }
+ return nil
+}
@@ -1,9 +1,15 @@
+// Package chat provides UI components for displaying and managing chat messages.
+// It defines message item types that can be rendered in a list view, including
+// support for highlighting, focusing, and caching rendered content.
package chat
import (
"image"
+ "strings"
+ tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/crush/internal/message"
+ "github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/list"
"github.com/charmbracelet/crush/internal/ui/styles"
)
@@ -20,6 +26,11 @@ type Identifiable interface {
ID() string
}
+type Animatable interface {
+ StartAnimation() tea.Cmd
+ Animate(msg anim.StepMsg) tea.Cmd
+}
+
// 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 {
@@ -84,7 +95,7 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
// cachedMessageItem caches rendered message content to avoid re-rendering.
//
-// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
//
// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
// the issue with that could be memory usage
@@ -111,6 +122,23 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
c.height = height
}
+// clearCache clears the cached render.
+func (c *cachedMessageItem) clearCache() {
+ c.rendered = ""
+ c.width = 0
+ c.height = 0
+}
+
+// focusableMessageItem is a base struct for message items that can be focused.
+type focusableMessageItem struct {
+ focused bool
+}
+
+// SetFocused implements MessageItem.
+func (f *focusableMessageItem) SetFocused(focused bool) {
+ f.focused = focused
+}
+
// cappedMessageWidth returns the maximum width for message content for readability.
func cappedMessageWidth(availableWidth int) int {
return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
@@ -125,10 +153,25 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
switch msg.Role {
case message.User:
return []MessageItem{NewUserMessageItem(sty, msg)}
+ case message.Assistant:
+ var items []MessageItem
+ if shouldRenderAssistantMessage(msg) {
+ items = append(items, NewAssistantMessageItem(sty, msg))
+ }
+ return items
}
return []MessageItem{}
}
+func shouldRenderAssistantMessage(msg *message.Message) bool {
+ content := strings.TrimSpace(msg.Content().Text)
+ thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
+ isError := msg.FinishReason() == message.FinishReasonError
+ isCancelled := msg.FinishReason() == message.FinishReasonCanceled
+ hasToolCalls := len(msg.ToolCalls()) > 0
+ return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
+}
+
// 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.
@@ -16,9 +16,10 @@ import (
type UserMessageItem struct {
*highlightableMessageItem
*cachedMessageItem
+ *focusableMessageItem
+
message *message.Message
sty *styles.Styles
- focused bool
}
// NewUserMessageItem creates a new UserMessageItem.
@@ -26,9 +27,9 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageIte
return &UserMessageItem{
highlightableMessageItem: defaultHighlighter(sty),
cachedMessageItem: &cachedMessageItem{},
+ focusableMessageItem: &focusableMessageItem{},
message: message,
sty: sty,
- focused: false,
}
}
@@ -67,11 +68,6 @@ func (m *UserMessageItem) Render(width int) string {
return style.Render(m.renderHighlighted(content, cappedWidth, height))
}
-// SetFocused implements MessageItem.
-func (m *UserMessageItem) SetFocused(focused bool) {
- m.focused = focused
-}
-
// ID implements MessageItem.
func (m *UserMessageItem) ID() string {
return m.message.ID
@@ -2,15 +2,14 @@ package common
import (
"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
// the given styles and width.
-func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(t.Markdown),
+ glamour.WithStyles(sty.Markdown),
glamour.WithWordWrap(width),
)
return r
@@ -18,9 +17,9 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
// (plain text with structure) and the given width.
-func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
r, _ := glamour.NewTermRenderer(
- glamour.WithStyles(gstyles.ASCIIStyleConfig),
+ glamour.WithStyles(sty.PlainMarkdown),
glamour.WithWordWrap(width),
)
return r
@@ -1,6 +1,8 @@
package model
import (
+ tea "charm.land/bubbletea/v2"
+ "github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/list"
@@ -11,8 +13,9 @@ import (
// Chat represents the chat UI model that handles chat interactions and
// messages.
type Chat struct {
- com *common.Common
- list *list.List
+ com *common.Common
+ list *list.List
+ idInxMap map[string]int // Map of message IDs to their indices in the list
// Mouse state
mouseDown bool
@@ -27,7 +30,7 @@ type Chat struct {
// NewChat creates a new instance of [Chat] that handles chat interactions and
// messages.
func NewChat(com *common.Common) *Chat {
- c := &Chat{com: com}
+ c := &Chat{com: com, idInxMap: make(map[string]int)}
l := list.NewList()
l.SetGap(1)
l.RegisterRenderCallback(c.applyHighlightRange)
@@ -57,16 +60,11 @@ func (m *Chat) Len() int {
return m.list.Len()
}
-// PrependItems prepends new items to the chat list.
-func (m *Chat) PrependItems(items ...list.Item) {
- m.list.PrependItems(items...)
- m.list.ScrollToIndex(0)
-}
-
// SetMessages sets the chat messages to the provided list of message items.
func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
items := make([]list.Item, len(msgs))
for i, msg := range msgs {
+ m.idInxMap[msg.ID()] = i
items[i] = msg
}
m.list.SetItems(items...)
@@ -76,16 +74,25 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
// AppendMessages appends a new message item to the chat list.
func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
items := make([]list.Item, len(msgs))
+ indexOffset := len(m.idInxMap)
for i, msg := range msgs {
+ m.idInxMap[msg.ID()] = indexOffset + i
items[i] = msg
}
m.list.AppendItems(items...)
}
-// AppendItems appends new items to the chat list.
-func (m *Chat) AppendItems(items ...list.Item) {
- m.list.AppendItems(items...)
- m.list.ScrollToIndex(m.list.Len() - 1)
+// Animate animated items in the chat list.
+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
+ item, ok := m.idInxMap[msg.ID]
+ // Item with the given ID exists
+ if !ok {
+ return nil
+ }
+ if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
+ return animatable.Animate(msg)
+ }
+ return nil
}
// Focus sets the focus state of the chat component.
@@ -158,6 +165,19 @@ func (m *Chat) SelectLastInView() {
m.list.SelectLastInView()
}
+// GetMessageItem returns the message item at the given id.
+func (m *Chat) GetMessageItem(id string) chat.MessageItem {
+ idx, ok := m.idInxMap[id]
+ if !ok {
+ return nil
+ }
+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
+ if !ok {
+ return nil
+ }
+ return item
+}
+
// HandleMouseDown handles mouse down events for the chat component.
func (m *Chat) HandleMouseDown(x, y int) bool {
if m.list.Len() == 0 {
@@ -27,7 +27,7 @@ import (
"github.com/charmbracelet/crush/internal/pubsub"
"github.com/charmbracelet/crush/internal/session"
"github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
- "github.com/charmbracelet/crush/internal/tui/util"
+ "github.com/charmbracelet/crush/internal/ui/anim"
"github.com/charmbracelet/crush/internal/ui/chat"
"github.com/charmbracelet/crush/internal/ui/common"
"github.com/charmbracelet/crush/internal/ui/dialog"
@@ -203,9 +203,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
cmds = append(cmds, uiutil.ReportError(err))
break
}
- m.setSessionMessages(msgs)
+ if cmd := m.setSessionMessages(msgs); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
case pubsub.Event[message.Message]:
+ if m.session == nil || msg.Payload.SessionID != m.session.ID {
+ break
+ }
+ switch msg.Type {
+ case pubsub.CreatedEvent:
+ cmds = append(cmds, m.appendSessionMessage(msg.Payload))
+ case pubsub.UpdatedEvent:
+ cmds = append(cmds, m.updateSessionMessage(msg.Payload))
+ }
// TODO: Finish implementing me
// cmds = append(cmds, m.setMessageEvents(msg.Payload))
case pubsub.Event[history.File]:
@@ -304,6 +315,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
}
}
+ case anim.StepMsg:
+ if m.state == uiChat {
+ if cmd := m.chat.Animate(msg); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
case tea.KeyPressMsg:
if cmd := m.handleKeyPressMsg(msg); cmd != nil {
cmds = append(cmds, cmd)
@@ -336,7 +353,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
// setSessionMessages sets the messages for the current session in the chat
-func (m *UI) setSessionMessages(msgs []message.Message) {
+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
+ var cmds []tea.Cmd
// Build tool result map to link tool calls with their results
msgPtrs := make([]*message.Message, len(msgs))
for i := range msgs {
@@ -350,9 +368,47 @@ func (m *UI) setSessionMessages(msgs []message.Message) {
items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
}
+ // If the user switches between sessions while the agent is working we want
+ // to make sure the animations are shown.
+ for _, item := range items {
+ if animatable, ok := item.(chat.Animatable); ok {
+ if cmd := animatable.StartAnimation(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ }
+
m.chat.SetMessages(items...)
m.chat.ScrollToBottom()
m.chat.SelectLast()
+ return tea.Batch(cmds...)
+}
+
+func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
+ items := chat.GetMessageItems(m.com.Styles, &msg, nil)
+ var cmds []tea.Cmd
+ for _, item := range items {
+ if animatable, ok := item.(chat.Animatable); ok {
+ if cmd := animatable.StartAnimation(); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+ }
+ m.chat.AppendMessages(items...)
+ m.chat.ScrollToBottom()
+ return tea.Batch(cmds...)
+}
+
+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
+ existingItem := m.chat.GetMessageItem(msg.ID)
+ switch msg.Role {
+ case message.Assistant:
+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+ assistantItem.SetMessage(&msg)
+ }
+ }
+
+ return nil
}
func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -1161,33 +1217,31 @@ func (m *UI) renderSidebarLogo(width int) {
// sendMessage sends a message with the given content and attachments.
func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
- if m.session == nil {
- return uiutil.ReportError(fmt.Errorf("no session selected"))
+ if m.com.App.AgentCoordinator == nil {
+ return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
}
- session := *m.session
+
var cmds []tea.Cmd
- if m.session.ID == "" {
+ if m.session == nil || m.session.ID == "" {
newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
if err != nil {
return uiutil.ReportError(err)
}
- session = newSession
- cmds = append(cmds, m.loadSession(session.ID))
- }
- if m.com.App.AgentCoordinator == nil {
- return util.ReportError(fmt.Errorf("coder agent is not initialized"))
+ m.state = uiChat
+ m.session = &newSession
+ cmds = append(cmds, m.loadSession(newSession.ID))
}
- m.chat.ScrollToBottom()
+
cmds = append(cmds, func() tea.Msg {
- _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
+ _, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, attachments...)
if err != nil {
isCancelErr := errors.Is(err, context.Canceled)
isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
if isCancelErr || isPermissionErr {
return nil
}
- return util.InfoMsg{
- Type: util.InfoTypeError,
+ return uiutil.InfoMsg{
+ Type: uiutil.InfoTypeError,
Msg: err.Error(),
}
}
@@ -85,7 +85,8 @@ type Styles struct {
ItemOnlineIcon lipgloss.Style
// Markdown & Chroma
- Markdown ansi.StyleConfig
+ Markdown ansi.StyleConfig
+ PlainMarkdown ansi.StyleConfig
// Inputs
TextInput textinput.Styles
@@ -195,8 +196,14 @@ type Styles struct {
Attachment lipgloss.Style
ToolCallFocused lipgloss.Style
ToolCallBlurred lipgloss.Style
- ThinkingFooter lipgloss.Style
SectionHeader lipgloss.Style
+
+ // Thinking section styles
+ ThinkingBox lipgloss.Style // Background for thinking content
+ ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
+ ThinkingFooterTitle lipgloss.Style // "Thought for" text
+ ThinkingFooterDuration lipgloss.Style // Duration value
+ ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text
}
}
@@ -667,6 +674,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),
@@ -892,9 +1062,15 @@ func DefaultStyles() Styles {
BorderLeft(true).
BorderForeground(greenDark)
s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
- s.Chat.Message.ThinkingFooter = s.Base
s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
+ // Thinking section styles
+ s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
+ s.Chat.Message.ThinkingTruncationHint = s.Muted
+ s.Chat.Message.ThinkingFooterTitle = s.Muted
+ s.Chat.Message.ThinkingFooterDuration = s.Subtle
+ s.Chat.Message.ThinkingFooterCancelled = s.Subtle
+
// Text selection.
s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
@@ -1,889 +0,0 @@
-package toolrender
-
-import (
- "cmp"
- "encoding/json"
- "fmt"
- "strings"
-
- "charm.land/lipgloss/v2"
- "charm.land/lipgloss/v2/tree"
- "github.com/charmbracelet/crush/internal/agent"
- "github.com/charmbracelet/crush/internal/agent/tools"
- "github.com/charmbracelet/crush/internal/ansiext"
- "github.com/charmbracelet/crush/internal/fsext"
- "github.com/charmbracelet/crush/internal/message"
- "github.com/charmbracelet/crush/internal/ui/common"
- "github.com/charmbracelet/crush/internal/ui/styles"
- "github.com/charmbracelet/x/ansi"
-)
-
-// responseContextHeight limits the number of lines displayed in tool output.
-const responseContextHeight = 10
-
-// RenderContext provides the context needed for rendering a tool call.
-type RenderContext struct {
- Call message.ToolCall
- Result message.ToolResult
- Cancelled bool
- IsNested bool
- Width int
- Styles *styles.Styles
-}
-
-// TextWidth returns the available width for content accounting for borders.
-func (rc *RenderContext) TextWidth() int {
- if rc.IsNested {
- return rc.Width - 6
- }
- return rc.Width - 5
-}
-
-// Fit truncates content to fit within the specified width with ellipsis.
-func (rc *RenderContext) Fit(content string, width int) string {
- lineStyle := rc.Styles.Muted
- dots := lineStyle.Render("…")
- return ansi.Truncate(content, width, dots)
-}
-
-// Render renders a tool call using the appropriate renderer based on tool name.
-func Render(ctx *RenderContext) string {
- switch ctx.Call.Name {
- case tools.ViewToolName:
- return renderView(ctx)
- case tools.EditToolName:
- return renderEdit(ctx)
- case tools.MultiEditToolName:
- return renderMultiEdit(ctx)
- case tools.WriteToolName:
- return renderWrite(ctx)
- case tools.BashToolName:
- return renderBash(ctx)
- case tools.JobOutputToolName:
- return renderJobOutput(ctx)
- case tools.JobKillToolName:
- return renderJobKill(ctx)
- case tools.FetchToolName:
- return renderSimpleFetch(ctx)
- case tools.AgenticFetchToolName:
- return renderAgenticFetch(ctx)
- case tools.WebFetchToolName:
- return renderWebFetch(ctx)
- case tools.DownloadToolName:
- return renderDownload(ctx)
- case tools.GlobToolName:
- return renderGlob(ctx)
- case tools.GrepToolName:
- return renderGrep(ctx)
- case tools.LSToolName:
- return renderLS(ctx)
- case tools.SourcegraphToolName:
- return renderSourcegraph(ctx)
- case tools.DiagnosticsToolName:
- return renderDiagnostics(ctx)
- case agent.AgentToolName:
- return renderAgent(ctx)
- default:
- return renderGeneric(ctx)
- }
-}
-
-// Helper functions
-
-func unmarshalParams(input string, target any) error {
- return json.Unmarshal([]byte(input), target)
-}
-
-type paramBuilder struct {
- args []string
-}
-
-func newParamBuilder() *paramBuilder {
- return ¶mBuilder{args: make([]string, 0)}
-}
-
-func (pb *paramBuilder) addMain(value string) *paramBuilder {
- if value != "" {
- pb.args = append(pb.args, value)
- }
- return pb
-}
-
-func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
- if value != "" {
- pb.args = append(pb.args, key, value)
- }
- return pb
-}
-
-func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
- if value {
- pb.args = append(pb.args, key, "true")
- }
- return pb
-}
-
-func (pb *paramBuilder) build() []string {
- return pb.args
-}
-
-func formatNonZero[T comparable](value T) string {
- var zero T
- if value == zero {
- return ""
- }
- return fmt.Sprintf("%v", value)
-}
-
-func makeHeader(ctx *RenderContext, toolName string, args []string) string {
- if ctx.IsNested {
- return makeNestedHeader(ctx, toolName, args)
- }
- s := ctx.Styles
- var icon string
- if ctx.Result.ToolCallID != "" {
- if ctx.Result.IsError {
- icon = s.Tool.IconError.Render()
- } else {
- icon = s.Tool.IconSuccess.Render()
- }
- } else if ctx.Cancelled {
- icon = s.Tool.IconCancelled.Render()
- } else {
- icon = s.Tool.IconPending.Render()
- }
- tool := s.Tool.NameNormal.Render(toolName)
- prefix := fmt.Sprintf("%s %s ", icon, tool)
- return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
-}
-
-func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
- s := ctx.Styles
- var icon string
- if ctx.Result.ToolCallID != "" {
- if ctx.Result.IsError {
- icon = s.Tool.IconError.Render()
- } else {
- icon = s.Tool.IconSuccess.Render()
- }
- } else if ctx.Cancelled {
- icon = s.Tool.IconCancelled.Render()
- } else {
- icon = s.Tool.IconPending.Render()
- }
- tool := s.Tool.NameNested.Render(toolName)
- prefix := fmt.Sprintf("%s %s ", icon, tool)
- return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
-}
-
-func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
- s := ctx.Styles
- if len(params) == 0 {
- return ""
- }
- mainParam := params[0]
- if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
- mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
- }
-
- if len(params) == 1 {
- return s.Tool.ParamMain.Render(mainParam)
- }
- otherParams := params[1:]
- if len(otherParams)%2 != 0 {
- otherParams = append(otherParams, "")
- }
- parts := make([]string, 0, len(otherParams)/2)
- for i := 0; i < len(otherParams); i += 2 {
- key := otherParams[i]
- value := otherParams[i+1]
- if value == "" {
- continue
- }
- parts = append(parts, fmt.Sprintf("%s=%s", key, value))
- }
-
- partsRendered := strings.Join(parts, ", ")
- remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
- if remainingWidth < 30 {
- return s.Tool.ParamMain.Render(mainParam)
- }
-
- if len(parts) > 0 {
- mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
- }
-
- return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
-}
-
-func earlyState(ctx *RenderContext, header string) (string, bool) {
- s := ctx.Styles
- message := ""
- switch {
- case ctx.Result.IsError:
- message = renderToolError(ctx)
- case ctx.Cancelled:
- message = s.Tool.StateCancelled.Render("Canceled.")
- case ctx.Result.ToolCallID == "":
- message = s.Tool.StateWaiting.Render("Waiting for tool response...")
- default:
- return "", false
- }
-
- message = s.Tool.BodyPadding.Render(message)
- return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
-}
-
-func renderToolError(ctx *RenderContext) string {
- s := ctx.Styles
- errTag := s.Tool.ErrorTag.Render("ERROR")
- msg := ctx.Result.Content
- if msg == "" {
- msg = "An error occurred"
- }
- truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
- return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
-}
-
-func joinHeaderBody(ctx *RenderContext, header, body string) string {
- s := ctx.Styles
- if body == "" {
- return header
- }
- body = s.Tool.BodyPadding.Render(body)
- return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
-}
-
-func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
- header := makeHeader(ctx, toolName, args)
- if ctx.IsNested {
- return header
- }
- if res, done := earlyState(ctx, header); done {
- return res
- }
- body := contentRenderer()
- return joinHeaderBody(ctx, header, body)
-}
-
-func renderError(ctx *RenderContext, message string) string {
- s := ctx.Styles
- header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
- errorTag := s.Tool.ErrorTag.Render("ERROR")
- message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
- return joinHeaderBody(ctx, header, errorTag+" "+message)
-}
-
-func renderPlainContent(ctx *RenderContext, content string) string {
- s := ctx.Styles
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
- lines := strings.Split(content, "\n")
-
- width := ctx.TextWidth() - 2
- var out []string
- for i, ln := range lines {
- if i >= responseContextHeight {
- break
- }
- ln = ansiext.Escape(ln)
- ln = " " + ln
- if len(ln) > width {
- ln = ctx.Fit(ln, width)
- }
- out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
- }
-
- if len(lines) > responseContextHeight {
- out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
- }
-
- return strings.Join(out, "\n")
-}
-
-func renderMarkdownContent(ctx *RenderContext, content string) string {
- s := ctx.Styles
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- content = strings.TrimSpace(content)
-
- width := ctx.TextWidth() - 2
- width = min(width, 120)
-
- renderer := common.PlainMarkdownRenderer(width)
- rendered, err := renderer.Render(content)
- if err != nil {
- return renderPlainContent(ctx, content)
- }
-
- lines := strings.Split(rendered, "\n")
-
- var out []string
- for i, ln := range lines {
- if i >= responseContextHeight {
- break
- }
- out = append(out, ln)
- }
-
- style := s.Tool.ContentLine
- if len(lines) > responseContextHeight {
- out = append(out, s.Tool.ContentTruncation.
- Width(width-2).
- Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
- }
-
- return style.Render(strings.Join(out, "\n"))
-}
-
-func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
- s := ctx.Styles
- content = strings.ReplaceAll(content, "\r\n", "\n")
- content = strings.ReplaceAll(content, "\t", " ")
- truncated := truncateHeight(content, responseContextHeight)
-
- lines := strings.Split(truncated, "\n")
- for i, ln := range lines {
- lines[i] = ansiext.Escape(ln)
- }
-
- bg := s.Tool.ContentCodeBg
- highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
- lines = strings.Split(highlighted, "\n")
-
- width := ctx.TextWidth() - 2
- gutterWidth := getDigits(offset+len(lines)) + 1
-
- var out []string
- for i, ln := range lines {
- lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
- gutter := s.Subtle.Render(lineNum + " ")
- ln = " " + ln
- if lipgloss.Width(gutter+ln) > width {
- ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
- }
- out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
- }
-
- contentLines := strings.Split(content, "\n")
- if len(contentLines) > responseContextHeight {
- out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
- }
-
- return strings.Join(out, "\n")
-}
-
-func getDigits(n int) int {
- if n == 0 {
- return 1
- }
- if n < 0 {
- n = -n
- }
-
- digits := 0
- for n > 0 {
- n /= 10
- digits++
- }
-
- return digits
-}
-
-func truncateHeight(content string, maxLines int) string {
- lines := strings.Split(content, "\n")
- if len(lines) <= maxLines {
- return content
- }
- return strings.Join(lines[:maxLines], "\n")
-}
-
-func prettifyToolName(name string) string {
- switch name {
- case "agent":
- return "Agent"
- case "bash":
- return "Bash"
- case "job_output":
- return "Job: Output"
- case "job_kill":
- return "Job: Kill"
- case "download":
- return "Download"
- case "edit":
- return "Edit"
- case "multiedit":
- return "Multi-Edit"
- case "fetch":
- return "Fetch"
- case "agentic_fetch":
- return "Agentic Fetch"
- case "web_fetch":
- return "Fetching"
- case "glob":
- return "Glob"
- case "grep":
- return "Grep"
- case "ls":
- return "List"
- case "sourcegraph":
- return "Sourcegraph"
- case "view":
- return "View"
- case "write":
- return "Write"
- case "lsp_references":
- return "Find References"
- case "lsp_diagnostics":
- return "Diagnostics"
- default:
- parts := strings.Split(name, "_")
- for i := range parts {
- if len(parts[i]) > 0 {
- parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
- }
- }
- return strings.Join(parts, " ")
- }
-}
-
-// Tool-specific renderers
-
-func renderGeneric(ctx *RenderContext) string {
- return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderView(ctx *RenderContext) string {
- var params tools.ViewParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid view parameters")
- }
-
- file := fsext.PrettyPath(params.FilePath)
- args := newParamBuilder().
- addMain(file).
- addKeyValue("limit", formatNonZero(params.Limit)).
- addKeyValue("offset", formatNonZero(params.Offset)).
- build()
-
- return renderWithParams(ctx, "View", args, func() string {
- var meta tools.ViewResponseMetadata
- if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
- return renderPlainContent(ctx, ctx.Result.Content)
- }
- return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
- })
-}
-
-func renderEdit(ctx *RenderContext) string {
- s := ctx.Styles
- var params tools.EditParams
- var args []string
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
- file := fsext.PrettyPath(params.FilePath)
- args = newParamBuilder().addMain(file).build()
- }
-
- return renderWithParams(ctx, "Edit", args, func() string {
- var meta tools.EditResponseMetadata
- if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
- return renderPlainContent(ctx, ctx.Result.Content)
- }
-
- formatter := common.DiffFormatter(ctx.Styles).
- Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
- After(fsext.PrettyPath(params.FilePath), meta.NewContent).
- Width(ctx.TextWidth() - 2)
- if ctx.TextWidth() > 120 {
- formatter = formatter.Split()
- }
- formatted := formatter.String()
- if lipgloss.Height(formatted) > responseContextHeight {
- contentLines := strings.Split(formatted, "\n")
- truncateMessage := s.Tool.DiffTruncation.
- Width(ctx.TextWidth() - 2).
- Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
- formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
- }
- return formatted
- })
-}
-
-func renderMultiEdit(ctx *RenderContext) string {
- s := ctx.Styles
- var params tools.MultiEditParams
- var args []string
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
- file := fsext.PrettyPath(params.FilePath)
- args = newParamBuilder().
- addMain(file).
- addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
- build()
- }
-
- return renderWithParams(ctx, "Multi-Edit", args, func() string {
- var meta tools.MultiEditResponseMetadata
- if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
- return renderPlainContent(ctx, ctx.Result.Content)
- }
-
- formatter := common.DiffFormatter(ctx.Styles).
- Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
- After(fsext.PrettyPath(params.FilePath), meta.NewContent).
- Width(ctx.TextWidth() - 2)
- if ctx.TextWidth() > 120 {
- formatter = formatter.Split()
- }
- formatted := formatter.String()
- if lipgloss.Height(formatted) > responseContextHeight {
- contentLines := strings.Split(formatted, "\n")
- truncateMessage := s.Tool.DiffTruncation.
- Width(ctx.TextWidth() - 2).
- Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
- formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
- }
-
- // Add note about failed edits if any.
- if len(meta.EditsFailed) > 0 {
- noteTag := s.Tool.NoteTag.Render("NOTE")
- noteMsg := s.Tool.NoteMessage.Render(
- fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
- formatted = formatted + "\n\n" + noteTag + " " + noteMsg
- }
-
- return formatted
- })
-}
-
-func renderWrite(ctx *RenderContext) string {
- var params tools.WriteParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid write parameters")
- }
-
- file := fsext.PrettyPath(params.FilePath)
- args := newParamBuilder().addMain(file).build()
-
- return renderWithParams(ctx, "Write", args, func() string {
- return renderCodeContent(ctx, params.FilePath, params.Content, 0)
- })
-}
-
-func renderBash(ctx *RenderContext) string {
- var params tools.BashParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid bash parameters")
- }
-
- cmd := strings.ReplaceAll(params.Command, "\n", " ")
- cmd = strings.ReplaceAll(cmd, "\t", " ")
- args := newParamBuilder().
- addMain(cmd).
- addFlag("background", params.RunInBackground).
- build()
-
- if ctx.Call.Finished {
- var meta tools.BashResponseMetadata
- _ = unmarshalParams(ctx.Result.Metadata, &meta)
- if meta.Background {
- description := cmp.Or(meta.Description, params.Command)
- width := ctx.TextWidth()
- if ctx.IsNested {
- width -= 4
- }
- header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
- if ctx.IsNested {
- return header
- }
- if res, done := earlyState(ctx, header); done {
- return res
- }
- content := "Command: " + params.Command + "\n" + ctx.Result.Content
- body := renderPlainContent(ctx, content)
- return joinHeaderBody(ctx, header, body)
- }
- }
-
- return renderWithParams(ctx, "Bash", args, func() string {
- var meta tools.BashResponseMetadata
- if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
- return renderPlainContent(ctx, ctx.Result.Content)
- }
- if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
- meta.Output = ctx.Result.Content
- }
-
- if meta.Output == "" {
- return ""
- }
- return renderPlainContent(ctx, meta.Output)
- })
-}
-
-func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
- s := ctx.Styles
- icon := s.Tool.JobIconPending.Render(styles.ToolPending)
- if ctx.Result.ToolCallID != "" {
- if ctx.Result.IsError {
- icon = s.Tool.JobIconError.Render(styles.ToolError)
- } else {
- icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
- }
- } else if ctx.Cancelled {
- icon = s.Muted.Render(styles.ToolPending)
- }
-
- toolName := s.Tool.JobToolName.Render("Bash")
- actionPart := s.Tool.JobAction.Render(action)
- pidPart := s.Tool.JobPID.Render(pid)
-
- prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
- remainingWidth := width - lipgloss.Width(prefix)
-
- descDisplay := ansi.Truncate(description, remainingWidth, "…")
- descDisplay = s.Tool.JobDescription.Render(descDisplay)
-
- return prefix + descDisplay
-}
-
-func renderJobOutput(ctx *RenderContext) string {
- var params tools.JobOutputParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid job output parameters")
- }
-
- width := ctx.TextWidth()
- if ctx.IsNested {
- width -= 4
- }
-
- var meta tools.JobOutputResponseMetadata
- _ = unmarshalParams(ctx.Result.Metadata, &meta)
- description := cmp.Or(meta.Description, meta.Command)
-
- header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
- if ctx.IsNested {
- return header
- }
- if res, done := earlyState(ctx, header); done {
- return res
- }
- body := renderPlainContent(ctx, ctx.Result.Content)
- return joinHeaderBody(ctx, header, body)
-}
-
-func renderJobKill(ctx *RenderContext) string {
- var params tools.JobKillParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid job kill parameters")
- }
-
- width := ctx.TextWidth()
- if ctx.IsNested {
- width -= 4
- }
-
- var meta tools.JobKillResponseMetadata
- _ = unmarshalParams(ctx.Result.Metadata, &meta)
- description := cmp.Or(meta.Description, meta.Command)
-
- header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
- if ctx.IsNested {
- return header
- }
- if res, done := earlyState(ctx, header); done {
- return res
- }
- body := renderPlainContent(ctx, ctx.Result.Content)
- return joinHeaderBody(ctx, header, body)
-}
-
-func renderSimpleFetch(ctx *RenderContext) string {
- var params tools.FetchParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid fetch parameters")
- }
-
- args := newParamBuilder().
- addMain(params.URL).
- addKeyValue("format", params.Format).
- addKeyValue("timeout", formatNonZero(params.Timeout)).
- build()
-
- return renderWithParams(ctx, "Fetch", args, func() string {
- path := "file." + params.Format
- return renderCodeContent(ctx, path, ctx.Result.Content, 0)
- })
-}
-
-func renderAgenticFetch(ctx *RenderContext) string {
- // TODO: Implement nested tool call rendering with tree.
- return renderGeneric(ctx)
-}
-
-func renderWebFetch(ctx *RenderContext) string {
- var params tools.WebFetchParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid web fetch parameters")
- }
-
- args := newParamBuilder().addMain(params.URL).build()
-
- return renderWithParams(ctx, "Fetching", args, func() string {
- return renderMarkdownContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderDownload(ctx *RenderContext) string {
- var params tools.DownloadParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid download parameters")
- }
-
- args := newParamBuilder().
- addMain(params.URL).
- addKeyValue("file", fsext.PrettyPath(params.FilePath)).
- addKeyValue("timeout", formatNonZero(params.Timeout)).
- build()
-
- return renderWithParams(ctx, "Download", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderGlob(ctx *RenderContext) string {
- var params tools.GlobParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid glob parameters")
- }
-
- args := newParamBuilder().
- addMain(params.Pattern).
- addKeyValue("path", params.Path).
- build()
-
- return renderWithParams(ctx, "Glob", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderGrep(ctx *RenderContext) string {
- var params tools.GrepParams
- var args []string
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil {
- args = newParamBuilder().
- addMain(params.Pattern).
- addKeyValue("path", params.Path).
- addKeyValue("include", params.Include).
- addFlag("literal", params.LiteralText).
- build()
- }
-
- return renderWithParams(ctx, "Grep", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderLS(ctx *RenderContext) string {
- var params tools.LSParams
- path := cmp.Or(params.Path, ".")
- args := newParamBuilder().addMain(path).build()
-
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err == nil && params.Path != "" {
- args = newParamBuilder().addMain(params.Path).build()
- }
-
- return renderWithParams(ctx, "List", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderSourcegraph(ctx *RenderContext) string {
- var params tools.SourcegraphParams
- if err := unmarshalParams(ctx.Call.Input, ¶ms); err != nil {
- return renderError(ctx, "Invalid sourcegraph parameters")
- }
-
- args := newParamBuilder().
- addMain(params.Query).
- addKeyValue("count", formatNonZero(params.Count)).
- addKeyValue("context", formatNonZero(params.ContextWindow)).
- build()
-
- return renderWithParams(ctx, "Sourcegraph", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderDiagnostics(ctx *RenderContext) string {
- args := newParamBuilder().addMain("project").build()
-
- return renderWithParams(ctx, "Diagnostics", args, func() string {
- return renderPlainContent(ctx, ctx.Result.Content)
- })
-}
-
-func renderAgent(ctx *RenderContext) string {
- s := ctx.Styles
- var params agent.AgentParams
- unmarshalParams(ctx.Call.Input, ¶ms)
-
- prompt := params.Prompt
- prompt = strings.ReplaceAll(prompt, "\n", " ")
-
- header := makeHeader(ctx, "Agent", []string{})
- if res, done := earlyState(ctx, header); ctx.Cancelled && done {
- return res
- }
- taskTag := s.Tool.AgentTaskTag.Render("Task")
- remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
- remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
- prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- header,
- "",
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- taskTag,
- " ",
- prompt,
- ),
- )
- childTools := tree.Root(header)
-
- // TODO: Render nested tool calls when available.
-
- parts := []string{
- childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
- }
-
- if ctx.Result.ToolCallID == "" {
- // Pending state - would show animation in TUI.
- parts = append(parts, "", s.Subtle.Render("Working..."))
- }
-
- header = lipgloss.JoinVertical(
- lipgloss.Left,
- parts...,
- )
-
- if ctx.Result.ToolCallID == "" {
- return header
- }
-
- body := renderMarkdownContent(ctx, ctx.Result.Content)
- return joinHeaderBody(ctx, header, body)
-}
-
-func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
- return func(children tree.Children, i int) string {
- if children.Length()-1 == i {
- return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
- }
- return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
- }
-}