From 3d9d4e6a3d2c800d52269b0d234e10df56029a2f Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:35:09 +0100 Subject: [PATCH 01/10] refactor(chat): simple assistant message --- cspell.json | 2 +- 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, 1006 insertions(+), 935 deletions(-) create mode 100644 internal/ui/anim/anim.go create mode 100644 internal/ui/chat/assistant.go delete mode 100644 internal/ui/toolrender/render.go diff --git a/cspell.json b/cspell.json index c0faed860420b452c9f22592a27a327b8895f2fc..368a9d5094dc6ebd1850f118533dc41050b0eb90 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"version":"0.2","language":"en","flagWords":[],"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim"]} \ No newline at end of file +{"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim","animatable"],"version":"0.2","flagWords":[],"language":"en"} \ No newline at end of file diff --git a/internal/ui/anim/anim.go b/internal/ui/anim/anim.go new file mode 100644 index 0000000000000000000000000000000000000000..3e159b102324a68bb93b8f9cbd3e128bf60dcf0f --- /dev/null +++ b/internal/ui/anim/anim.go @@ -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 +} diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go new file mode 100644 index 0000000000000000000000000000000000000000..a6d643d1df37eaf1f98736d0296f8e22d770209f --- /dev/null +++ b/internal/ui/chat/assistant.go @@ -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 +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 5fd52ff854e15fc7170bb58175196ad3aeb47306..53c0e5f45bb169b6ddbc92bc92262b4e47e3a509 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -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. diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go index b3e1bebb16b1bfebe9036b189ca0cfb42c234805..17033db31b92a193573482d60256cdb6ed3efd4c 100644 --- a/internal/ui/chat/user.go +++ b/internal/ui/chat/user.go @@ -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 diff --git a/internal/ui/common/markdown.go b/internal/ui/common/markdown.go index 361cbba2ff8bab34214f95980bd20b98a6ead62a..f5af8121d1667658725b4424a4ab303804c75b42 100644 --- a/internal/ui/common/markdown.go +++ b/internal/ui/common/markdown.go @@ -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 diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 66ae5bc51ad8d835350fc5edb510048d699d3977..5fa2dc8ed1d6ba4213daa6e9a7f198761e2d0568 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -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 { diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 93367cdd05842279984027f228e053d1504f1c52..5c738f6835a4666bd87315fb9ce43e498ba29d05 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -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(), } } diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index c78cdcacd2b4a3636ae07fc2f75ccdf9fe984d9b..f16a080662c160c9e7c1ac9ca05dc6dae362f988 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -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) 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) + " " - } -} From 95bae1017e11e4312ef98d78eed21eceb2211087 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:41:52 +0100 Subject: [PATCH 02/10] chore(chat): add some missing docs --- internal/ui/chat/messages.go | 4 ++++ internal/ui/model/ui.go | 3 +++ 2 files changed, 7 insertions(+) diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 53c0e5f45bb169b6ddbc92bc92262b4e47e3a509..3e12efefa4db1c9d78cbfe5a44374f4a918290f4 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -163,6 +163,10 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s return []MessageItem{} } +// shouldRenderAssistantMessage determines if an assistant message should be rendered +// +// In some cases the assistant message only has tools so we do not want to render an +// empty message. func shouldRenderAssistantMessage(msg *message.Message) bool { content := strings.TrimSpace(msg.Content().Text) thinking := strings.TrimSpace(msg.ReasoningContent().Thinking) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 5c738f6835a4666bd87315fb9ce43e498ba29d05..2caa9ac9b910a4439d1d69a62c595dce94239695 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -384,6 +384,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { return tea.Batch(cmds...) } +// appendSessionMessage appends a new message to the current session in the chat func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { items := chat.GetMessageItems(m.com.Styles, &msg, nil) var cmds []tea.Cmd @@ -399,6 +400,8 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { return tea.Batch(cmds...) } +// updateSessionMessage updates an existing message in the current session in the chat +// INFO: currently only updates the assistant when I add tools this will get a bit more complex func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd { existingItem := m.chat.GetMessageItem(msg.ID) switch msg.Role { From 8cea0883824de2cfb0627069333c579ea41648ee Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:45:23 +0100 Subject: [PATCH 03/10] chore(chat): remove unused style --- internal/ui/styles/styles.go | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index f16a080662c160c9e7c1ac9ca05dc6dae362f988..d6f96474c7daf3397ebebb9b819b2388790d7a95 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -199,11 +199,10 @@ type Styles struct { 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 + 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 } } @@ -1069,7 +1068,6 @@ func DefaultStyles() Styles { 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) From b8d39c6abdf3d8ff47b6c0cdb9b0d2fbb62ce1de Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 16:51:48 +0100 Subject: [PATCH 04/10] chore(chat): some more docs missing --- internal/ui/chat/assistant.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index a6d643d1df37eaf1f98736d0296f8e22d770209f..e4153075459473bca47c17f83ffbcfe81f151909 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -52,6 +52,7 @@ func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) Messa return a } +// StartAnimation starts the assistant message animation if it should be spinning. func (a *AssistantMessageItem) StartAnimation() tea.Cmd { if !a.isSpinning() { return nil @@ -59,6 +60,7 @@ func (a *AssistantMessageItem) StartAnimation() tea.Cmd { return a.anim.Start() } +// Animate progresses the assistant message animation if it should be spinning. func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd { if !a.isSpinning() { return nil @@ -103,6 +105,7 @@ func (a *AssistantMessageItem) Render(width int) string { return style.Render(highlightedContent) } +// renderMessageContent renders the message content including thinking, main content, and finish reason. func (a *AssistantMessageItem) renderMessageContent(width int) string { var messageParts []string thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking) From ed18aac98e9f06cc1ad6aa2bef0f779ff894dc43 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 18:38:34 +0100 Subject: [PATCH 05/10] refactor(chat): only show animations for items that are visible --- internal/ui/list/list.go | 12 ++--- internal/ui/model/chat.go | 99 +++++++++++++++++++++++++++++++++------ internal/ui/model/ui.go | 88 +++++++++++++++++++++++++--------- 3 files changed, 156 insertions(+), 43 deletions(-) diff --git a/internal/ui/list/list.go b/internal/ui/list/list.go index 17766cd52506132322c051fffaf33de613332315..3e9fe124b0ddf1e55b3e920bc2828d4efabbc996 100644 --- a/internal/ui/list/list.go +++ b/internal/ui/list/list.go @@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) { } } -// findVisibleItems finds the range of items that are visible in the viewport. +// VisibleItemIndices finds the range of items that are visible in the viewport. // This is used for checking if selected item is in view. -func (l *List) findVisibleItems() (startIdx, endIdx int) { +func (l *List) VisibleItemIndices() (startIdx, endIdx int) { if len(l.items) == 0 { return 0, 0 } @@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() { return } - startIdx, endIdx := l.findVisibleItems() + startIdx, endIdx := l.VisibleItemIndices() if l.selectedIdx < startIdx { // Selected item is above the visible range l.offsetIdx = l.selectedIdx @@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool { if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) { return false } - startIdx, endIdx := l.findVisibleItems() + startIdx, endIdx := l.VisibleItemIndices() return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx } @@ -453,13 +453,13 @@ func (l *List) SelectedItem() Item { // SelectFirstInView selects the first item currently in view. func (l *List) SelectFirstInView() { - startIdx, _ := l.findVisibleItems() + startIdx, _ := l.VisibleItemIndices() l.selectedIdx = startIdx } // SelectLastInView selects the last item currently in view. func (l *List) SelectLastInView() { - _, endIdx := l.findVisibleItems() + _, endIdx := l.VisibleItemIndices() l.selectedIdx = endIdx } diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 5fa2dc8ed1d6ba4213daa6e9a7f198761e2d0568..76b52ce7be311656a1e546bb6f5e261333c95e0a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -17,6 +17,11 @@ type Chat struct { list *list.List idInxMap map[string]int // Map of message IDs to their indices in the list + // Animation visibility optimization: track animations paused due to items + // being scrolled out of view. When items become visible again, their + // animations are restarted. + pausedAnimations map[string]struct{} + // Mouse state mouseDown bool mouseDownItem int // Item index where mouse was pressed @@ -30,7 +35,11 @@ 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, idInxMap: make(map[string]int)} + c := &Chat{ + com: com, + idInxMap: make(map[string]int), + pausedAnimations: make(map[string]struct{}), + } l := list.NewList() l.SetGap(1) l.RegisterRenderCallback(c.applyHighlightRange) @@ -82,17 +91,69 @@ func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { m.list.AppendItems(items...) } -// Animate animated items in the chat list. +// Animate animates items in the chat list. Only propagates animation messages +// to visible items to save CPU. When items are not visible, their animation ID +// is tracked so it can be restarted when they become visible again. func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd { - item, ok := m.idInxMap[msg.ID] - // Item with the given ID exists + idx, ok := m.idInxMap[msg.ID] + if !ok { + return nil + } + + animatable, ok := m.list.ItemAt(idx).(chat.Animatable) if !ok { return nil } - if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok { - return animatable.Animate(msg) + + // Check if item is currently visible. + startIdx, endIdx := m.list.VisibleItemIndices() + isVisible := idx >= startIdx && idx <= endIdx + + if !isVisible { + // Item not visible - pause animation by not propagating. + // Track it so we can restart when it becomes visible. + m.pausedAnimations[msg.ID] = struct{}{} + return nil + } + + // Item is visible - remove from paused set and animate. + delete(m.pausedAnimations, msg.ID) + return animatable.Animate(msg) +} + +// RestartPausedVisibleAnimations restarts animations for items that were paused +// due to being scrolled out of view but are now visible again. +func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd { + if len(m.pausedAnimations) == 0 { + return nil + } + + startIdx, endIdx := m.list.VisibleItemIndices() + var cmds []tea.Cmd + + for id := range m.pausedAnimations { + idx, ok := m.idInxMap[id] + if !ok { + // Item no longer exists. + delete(m.pausedAnimations, id) + continue + } + + if idx >= startIdx && idx <= endIdx { + // Item is now visible - restart its animation. + if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok { + if cmd := animatable.StartAnimation(); cmd != nil { + cmds = append(cmds, cmd) + } + } + delete(m.pausedAnimations, id) + } + } + + if len(cmds) == 0 { + return nil } - return nil + return tea.Batch(cmds...) } // Focus sets the focus state of the chat component. @@ -105,24 +166,32 @@ func (m *Chat) Blur() { m.list.Blur() } -// ScrollToTop scrolls the chat view to the top. -func (m *Chat) ScrollToTop() { +// ScrollToTop scrolls the chat view to the top and returns a command to restart +// any paused animations that are now visible. +func (m *Chat) ScrollToTop() tea.Cmd { m.list.ScrollToTop() + return m.RestartPausedVisibleAnimations() } -// ScrollToBottom scrolls the chat view to the bottom. -func (m *Chat) ScrollToBottom() { +// ScrollToBottom scrolls the chat view to the bottom and returns a command to +// restart any paused animations that are now visible. +func (m *Chat) ScrollToBottom() tea.Cmd { m.list.ScrollToBottom() + return m.RestartPausedVisibleAnimations() } -// ScrollBy scrolls the chat view by the given number of line deltas. -func (m *Chat) ScrollBy(lines int) { +// ScrollBy scrolls the chat view by the given number of line deltas and returns +// a command to restart any paused animations that are now visible. +func (m *Chat) ScrollBy(lines int) tea.Cmd { m.list.ScrollBy(lines) + return m.RestartPausedVisibleAnimations() } -// ScrollToSelected scrolls the chat view to the selected item. -func (m *Chat) ScrollToSelected() { +// ScrollToSelected scrolls the chat view to the selected item and returns a +// command to restart any paused animations that are now visible. +func (m *Chat) ScrollToSelected() tea.Cmd { m.list.ScrollToSelected() + return m.RestartPausedVisibleAnimations() } // SelectedItemInView returns whether the selected item is currently in view. diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2caa9ac9b910a4439d1d69a62c595dce94239695..dc581d4febe19a73ae12618a44f9ccd8bdf802b8 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -268,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case uiChat: if msg.Y <= 0 { - m.chat.ScrollBy(-1) + if cmd := m.chat.ScrollBy(-1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } else if msg.Y >= m.chat.Height()-1 { - m.chat.ScrollBy(1) + if cmd := m.chat.ScrollBy(1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } @@ -302,16 +310,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: switch msg.Button { case tea.MouseWheelUp: - m.chat.ScrollBy(-5) + if cmd := m.chat.ScrollBy(-5); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case tea.MouseWheelDown: - m.chat.ScrollBy(5) + if cmd := m.chat.ScrollBy(5); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } } } @@ -379,7 +395,9 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { } m.chat.SetMessages(items...) - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLast() return tea.Batch(cmds...) } @@ -396,7 +414,9 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } return tea.Batch(cmds...) } @@ -558,40 +578,64 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() case key.Matches(msg, m.keyMap.Chat.Up): - m.chat.ScrollBy(-1) + if cmd := m.chat.ScrollBy(-1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case key.Matches(msg, m.keyMap.Chat.Down): - m.chat.ScrollBy(1) + if cmd := m.chat.ScrollBy(1); cmd != nil { + cmds = append(cmds, cmd) + } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } } case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() - m.chat.ScrollToSelected() + if cmd := m.chat.ScrollToSelected(); cmd != nil { + cmds = append(cmds, cmd) + } case key.Matches(msg, m.keyMap.Chat.HalfPageUp): - m.chat.ScrollBy(-m.chat.Height() / 2) + if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.HalfPageDown): - m.chat.ScrollBy(m.chat.Height() / 2) + if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.PageUp): - m.chat.ScrollBy(-m.chat.Height()) + if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.PageDown): - m.chat.ScrollBy(m.chat.Height()) + if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.Home): - m.chat.ScrollToTop() + if cmd := m.chat.ScrollToTop(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectFirst() case key.Matches(msg, m.keyMap.Chat.End): - m.chat.ScrollToBottom() + if cmd := m.chat.ScrollToBottom(); cmd != nil { + cmds = append(cmds, cmd) + } m.chat.SelectLast() default: handleGlobalKeys(msg) From ff9cbf656f77745a147b285ea1a6311ca1533210 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 18:41:12 +0100 Subject: [PATCH 06/10] fix(chat): only spin when there is no and no tool calls --- internal/ui/chat/assistant.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index e4153075459473bca47c17f83ffbcfe81f151909..66fe97e1bc5a7d6242200835a72136c098935ab1 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -215,7 +215,9 @@ func (a *AssistantMessageItem) renderError(width int) string { func (a *AssistantMessageItem) isSpinning() bool { isThinking := a.message.IsThinking() isFinished := a.message.IsFinished() - return isThinking || !isFinished + hasContent := strings.TrimSpace(a.message.Content().Text) != "" + hasToolCalls := len(a.message.ToolCalls()) > 0 + return (isThinking || !isFinished) && !hasContent && !hasToolCalls } // SetMessage is used to update the underlying message. From b6185b1d9e8f0c58531c3567750d43f20274489b Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 20:08:52 +0100 Subject: [PATCH 07/10] fix(chat): race condition --- internal/ui/model/ui.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index dc581d4febe19a73ae12618a44f9ccd8bdf802b8..967452e3e81aa7c92c75b02c184dd6f53aaa164a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1279,8 +1279,10 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C cmds = append(cmds, m.loadSession(newSession.ID)) } + // Capture session ID to avoid race with main goroutine updating m.session. + sessionID := m.session.ID cmds = append(cmds, func() tea.Msg { - _, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, attachments...) + _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...) if err != nil { isCancelErr := errors.Is(err, context.Canceled) isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied) From d5f0987bd52d458f86cdfadec0cad4920bb2ce2a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 17 Dec 2025 20:48:01 +0100 Subject: [PATCH 08/10] fix(chat): reset index and paused animations --- internal/ui/model/chat.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 76b52ce7be311656a1e546bb6f5e261333c95e0a..685d541da9f7bf5240a6bc9302098a1ed79153bb 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -71,6 +71,9 @@ func (m *Chat) Len() int { // SetMessages sets the chat messages to the provided list of message items. func (m *Chat) SetMessages(msgs ...chat.MessageItem) { + m.idInxMap = make(map[string]int) + m.pausedAnimations = make(map[string]struct{}) + items := make([]list.Item, len(msgs)) for i, msg := range msgs { m.idInxMap[msg.ID()] = i @@ -83,7 +86,7 @@ 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) + indexOffset := m.list.Len() for i, msg := range msgs { m.idInxMap[msg.ID()] = indexOffset + i items[i] = msg From 4316ef3c2f6b14d242e04d569d99d4fcea829ce8 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 08:44:11 +0100 Subject: [PATCH 09/10] feat(chat): expandable thinking for assistant --- internal/ui/chat/assistant.go | 19 +++++++++++++++++++ internal/ui/chat/messages.go | 6 ++++++ internal/ui/model/chat.go | 7 +++++++ internal/ui/model/keys.go | 6 +++++- internal/ui/model/ui.go | 2 ++ 5 files changed, 39 insertions(+), 1 deletion(-) diff --git a/internal/ui/chat/assistant.go b/internal/ui/chat/assistant.go index 66fe97e1bc5a7d6242200835a72136c098935ab1..331167bdc13d90233f881343ce607c07e0ef700c 100644 --- a/internal/ui/chat/assistant.go +++ b/internal/ui/chat/assistant.go @@ -230,3 +230,22 @@ func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd { } return nil } + +// ToggleExpanded toggles the expanded state of the thinking box. +func (a *AssistantMessageItem) ToggleExpanded() { + a.thinkingExpanded = !a.thinkingExpanded + a.clearCache() +} + +// HandleMouseClick implements MouseClickable. +func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool { + if btn != ansi.MouseLeft { + return false + } + // check if the click is within the thinking box + if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight { + a.ToggleExpanded() + return true + } + return false +} diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 3e12efefa4db1c9d78cbfe5a44374f4a918290f4..fe1e0c8f891c14f90c7e58cf8a99deb16d165765 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -26,11 +26,17 @@ type Identifiable interface { ID() string } +// Animatable is an interface for items that support animation. type Animatable interface { StartAnimation() tea.Cmd Animate(msg anim.StepMsg) tea.Cmd } +// Expandable is an interface for items that can be expanded or collapsed. +type Expandable interface { + ToggleExpanded() +} + // 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 { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 685d541da9f7bf5240a6bc9302098a1ed79153bb..2844cb81619e99956ba5895686d82e65eef92ce7 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -250,6 +250,13 @@ func (m *Chat) GetMessageItem(id string) chat.MessageItem { return item } +// ToggleExpandedSelectedItem expands the selected message item if it is expandable. +func (m *Chat) ToggleExpandedSelectedItem() { + if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok { + expandable.ToggleExpanded() + } +} + // HandleMouseDown handles mouse down events for the chat component. func (m *Chat) HandleMouseDown(x, y int) bool { if m.list.Len() == 0 { diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index d421c00ca032a97b424fafe6442a243fc98080b1..f2f3fc9106c92effe38b48dd6f664cb8617f9443 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -37,6 +37,7 @@ type KeyMap struct { End key.Binding Copy key.Binding ClearHighlight key.Binding + Expand key.Binding } Initialize struct { @@ -205,7 +206,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("esc", "alt+esc"), key.WithHelp("esc", "clear selection"), ) - + km.Chat.Expand = key.NewBinding( + key.WithKeys("space"), + key.WithHelp("space", "expand/collapse"), + ) km.Initialize.Yes = key.NewBinding( key.WithKeys("y", "Y"), key.WithHelp("y", "yes"), diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 967452e3e81aa7c92c75b02c184dd6f53aaa164a..57a7113ae5b432436f23ec79e0d6d5cacdbb94f0 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -577,6 +577,8 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) m.chat.Blur() + case key.Matches(msg, m.keyMap.Chat.Expand): + m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): if cmd := m.chat.ScrollBy(-1); cmd != nil { cmds = append(cmds, cmd) From b0dd9830ad278754699406aff3c44b97d01fb8be Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 18 Dec 2025 17:30:55 +0100 Subject: [PATCH 10/10] chore(chat): rename funcs --- internal/ui/model/chat.go | 16 +++++++------- internal/ui/model/ui.go | 44 +++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 2844cb81619e99956ba5895686d82e65eef92ce7..7d6ad220d27426dc44fb3d3a277390b1e2f8b24a 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -169,30 +169,30 @@ func (m *Chat) Blur() { m.list.Blur() } -// ScrollToTop scrolls the chat view to the top and returns a command to restart +// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart // any paused animations that are now visible. -func (m *Chat) ScrollToTop() tea.Cmd { +func (m *Chat) ScrollToTopAndAnimate() tea.Cmd { m.list.ScrollToTop() return m.RestartPausedVisibleAnimations() } -// ScrollToBottom scrolls the chat view to the bottom and returns a command to +// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to // restart any paused animations that are now visible. -func (m *Chat) ScrollToBottom() tea.Cmd { +func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd { m.list.ScrollToBottom() return m.RestartPausedVisibleAnimations() } -// ScrollBy scrolls the chat view by the given number of line deltas and returns +// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns // a command to restart any paused animations that are now visible. -func (m *Chat) ScrollBy(lines int) tea.Cmd { +func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd { m.list.ScrollBy(lines) return m.RestartPausedVisibleAnimations() } -// ScrollToSelected scrolls the chat view to the selected item and returns a +// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a // command to restart any paused animations that are now visible. -func (m *Chat) ScrollToSelected() tea.Cmd { +func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd { m.list.ScrollToSelected() return m.RestartPausedVisibleAnimations() } diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57a7113ae5b432436f23ec79e0d6d5cacdbb94f0..b5e4a9b53dd90a685329174fe497e22ab04700f2 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -268,22 +268,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.state { case uiChat: if msg.Y <= 0 { - if cmd := m.chat.ScrollBy(-1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } } else if msg.Y >= m.chat.Height()-1 { - if cmd := m.chat.ScrollBy(1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } @@ -310,22 +310,22 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case uiChat: switch msg.Button { case tea.MouseWheelUp: - if cmd := m.chat.ScrollBy(-5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case tea.MouseWheelDown: - if cmd := m.chat.ScrollBy(5); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } @@ -395,7 +395,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd { } m.chat.SetMessages(items...) - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLast() @@ -414,7 +414,7 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd { } } m.chat.AppendMessages(items...) - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } return tea.Batch(cmds...) @@ -580,62 +580,62 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { case key.Matches(msg, m.keyMap.Chat.Expand): m.chat.ToggleExpandedSelectedItem() case key.Matches(msg, m.keyMap.Chat.Up): - if cmd := m.chat.ScrollBy(-1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case key.Matches(msg, m.keyMap.Chat.Down): - if cmd := m.chat.ScrollBy(1); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil { cmds = append(cmds, cmd) } if !m.chat.SelectedItemInView() { m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } } case key.Matches(msg, m.keyMap.Chat.UpOneItem): m.chat.SelectPrev() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } case key.Matches(msg, m.keyMap.Chat.DownOneItem): m.chat.SelectNext() - if cmd := m.chat.ScrollToSelected(); cmd != nil { + if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } case key.Matches(msg, m.keyMap.Chat.HalfPageUp): - if cmd := m.chat.ScrollBy(-m.chat.Height() / 2); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.HalfPageDown): - if cmd := m.chat.ScrollBy(m.chat.Height() / 2); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.PageUp): - if cmd := m.chat.ScrollBy(-m.chat.Height()); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirstInView() case key.Matches(msg, m.keyMap.Chat.PageDown): - if cmd := m.chat.ScrollBy(m.chat.Height()); cmd != nil { + if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLastInView() case key.Matches(msg, m.keyMap.Chat.Home): - if cmd := m.chat.ScrollToTop(); cmd != nil { + if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectFirst() case key.Matches(msg, m.keyMap.Chat.End): - if cmd := m.chat.ScrollToBottom(); cmd != nil { + if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil { cmds = append(cmds, cmd) } m.chat.SelectLast()