on main help sign in
Merge pull request #1646 from charmbracelet/chat-ui-assistant-message
45e3310Ayman Bagabas
cspell.json+1−1
@@ -1 +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"]}
1+{"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"}
internal/ui/anim/anim.gonew file+445
@@ -0,0 +1,445 @@
1+// Package anim provides an animated spinner.
2+package anim
3+
4+import (
5+ "fmt"
6+ "image/color"
7+ "math/rand/v2"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/zeebo/xxh3"
13+
14+ tea "charm.land/bubbletea/v2"
15+ "charm.land/lipgloss/v2"
16+ "github.com/lucasb-eyer/go-colorful"
17+
18+ "github.com/charmbracelet/crush/internal/csync"
19+)
20+
21+const (
22+ fps = 20
23+ initialChar = '.'
24+ labelGap = " "
25+ labelGapWidth = 1
26+
27+ // Periods of ellipsis animation speed in steps.
28+ //
29+ // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
30+ // change every 8 frames (400 milliseconds).
31+ ellipsisAnimSpeed = 8
32+
33+ // The maximum amount of time that can pass before a character appears.
34+ // This is used to create a staggered entrance effect.
35+ maxBirthOffset = time.Second
36+
37+ // Number of frames to prerender for the animation. After this number
38+ // of frames, the animation will loop. This only applies when color
39+ // cycling is disabled.
40+ prerenderedFrames = 10
41+
42+ // Default number of cycling chars.
43+ defaultNumCyclingChars = 10
44+)
45+
46+// Default colors for gradient.
47+var (
48+ defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
49+ defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
50+ defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
51+)
52+
53+var (
54+ availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
55+ ellipsisFrames = []string{".", "..", "...", ""}
56+)
57+
58+// Internal ID management. Used during animating to ensure that frame messages
59+// are received only by spinner components that sent them.
60+var lastID int64
61+
62+func nextID() int {
63+ return int(atomic.AddInt64(&lastID, 1))
64+}
65+
66+// Cache for expensive animation calculations
67+type animCache struct {
68+ initialFrames [][]string
69+ cyclingFrames [][]string
70+ width int
71+ labelWidth int
72+ label []string
73+ ellipsisFrames []string
74+}
75+
76+var animCacheMap = csync.NewMap[string, *animCache]()
77+
78+// settingsHash creates a hash key for the settings to use for caching
79+func settingsHash(opts Settings) string {
80+ h := xxh3.New()
81+ fmt.Fprintf(h, "%d-%s-%v-%v-%v-%t",
82+ opts.Size, opts.Label, opts.LabelColor, opts.GradColorA, opts.GradColorB, opts.CycleColors)
83+ return fmt.Sprintf("%x", h.Sum(nil))
84+}
85+
86+// StepMsg is a message type used to trigger the next step in the animation.
87+type StepMsg struct{ ID string }
88+
89+// Settings defines settings for the animation.
90+type Settings struct {
91+ ID string
92+ Size int
93+ Label string
94+ LabelColor color.Color
95+ GradColorA color.Color
96+ GradColorB color.Color
97+ CycleColors bool
98+}
99+
100+// Default settings.
101+const ()
102+
103+// Anim is a Bubble for an animated spinner.
104+type Anim struct {
105+ width int
106+ cyclingCharWidth int
107+ label *csync.Slice[string]
108+ labelWidth int
109+ labelColor color.Color
110+ startTime time.Time
111+ birthOffsets []time.Duration
112+ initialFrames [][]string // frames for the initial characters
113+ initialized atomic.Bool
114+ cyclingFrames [][]string // frames for the cycling characters
115+ step atomic.Int64 // current main frame step
116+ ellipsisStep atomic.Int64 // current ellipsis frame step
117+ ellipsisFrames *csync.Slice[string] // ellipsis animation frames
118+ id string
119+}
120+
121+// New creates a new Anim instance with the specified width and label.
122+func New(opts Settings) *Anim {
123+ a := &Anim{}
124+ // Validate settings.
125+ if opts.Size < 1 {
126+ opts.Size = defaultNumCyclingChars
127+ }
128+ if colorIsUnset(opts.GradColorA) {
129+ opts.GradColorA = defaultGradColorA
130+ }
131+ if colorIsUnset(opts.GradColorB) {
132+ opts.GradColorB = defaultGradColorB
133+ }
134+ if colorIsUnset(opts.LabelColor) {
135+ opts.LabelColor = defaultLabelColor
136+ }
137+
138+ if opts.ID != "" {
139+ a.id = opts.ID
140+ } else {
141+ a.id = fmt.Sprintf("%d", nextID())
142+ }
143+ a.startTime = time.Now()
144+ a.cyclingCharWidth = opts.Size
145+ a.labelColor = opts.LabelColor
146+
147+ // Check cache first
148+ cacheKey := settingsHash(opts)
149+ cached, exists := animCacheMap.Get(cacheKey)
150+
151+ if exists {
152+ // Use cached values
153+ a.width = cached.width
154+ a.labelWidth = cached.labelWidth
155+ a.label = csync.NewSliceFrom(cached.label)
156+ a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
157+ a.initialFrames = cached.initialFrames
158+ a.cyclingFrames = cached.cyclingFrames
159+ } else {
160+ // Generate new values and cache them
161+ a.labelWidth = lipgloss.Width(opts.Label)
162+
163+ // Total width of anim, in cells.
164+ a.width = opts.Size
165+ if opts.Label != "" {
166+ a.width += labelGapWidth + lipgloss.Width(opts.Label)
167+ }
168+
169+ // Render the label
170+ a.renderLabel(opts.Label)
171+
172+ // Pre-generate gradient.
173+ var ramp []color.Color
174+ numFrames := prerenderedFrames
175+ if opts.CycleColors {
176+ ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
177+ numFrames = a.width * 2
178+ } else {
179+ ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
180+ }
181+
182+ // Pre-render initial characters.
183+ a.initialFrames = make([][]string, numFrames)
184+ offset := 0
185+ for i := range a.initialFrames {
186+ a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
187+ for j := range a.initialFrames[i] {
188+ if j+offset >= len(ramp) {
189+ continue // skip if we run out of colors
190+ }
191+
192+ var c color.Color
193+ if j <= a.cyclingCharWidth {
194+ c = ramp[j+offset]
195+ } else {
196+ c = opts.LabelColor
197+ }
198+
199+ // Also prerender the initial character with Lip Gloss to avoid
200+ // processing in the render loop.
201+ a.initialFrames[i][j] = lipgloss.NewStyle().
202+ Foreground(c).
203+ Render(string(initialChar))
204+ }
205+ if opts.CycleColors {
206+ offset++
207+ }
208+ }
209+
210+ // Prerender scrambled rune frames for the animation.
211+ a.cyclingFrames = make([][]string, numFrames)
212+ offset = 0
213+ for i := range a.cyclingFrames {
214+ a.cyclingFrames[i] = make([]string, a.width)
215+ for j := range a.cyclingFrames[i] {
216+ if j+offset >= len(ramp) {
217+ continue // skip if we run out of colors
218+ }
219+
220+ // Also prerender the color with Lip Gloss here to avoid processing
221+ // in the render loop.
222+ r := availableRunes[rand.IntN(len(availableRunes))]
223+ a.cyclingFrames[i][j] = lipgloss.NewStyle().
224+ Foreground(ramp[j+offset]).
225+ Render(string(r))
226+ }
227+ if opts.CycleColors {
228+ offset++
229+ }
230+ }
231+
232+ // Cache the results
233+ labelSlice := make([]string, a.label.Len())
234+ for i, v := range a.label.Seq2() {
235+ labelSlice[i] = v
236+ }
237+ ellipsisSlice := make([]string, a.ellipsisFrames.Len())
238+ for i, v := range a.ellipsisFrames.Seq2() {
239+ ellipsisSlice[i] = v
240+ }
241+ cached = &animCache{
242+ initialFrames: a.initialFrames,
243+ cyclingFrames: a.cyclingFrames,
244+ width: a.width,
245+ labelWidth: a.labelWidth,
246+ label: labelSlice,
247+ ellipsisFrames: ellipsisSlice,
248+ }
249+ animCacheMap.Set(cacheKey, cached)
250+ }
251+
252+ // Random assign a birth to each character for a stagged entrance effect.
253+ a.birthOffsets = make([]time.Duration, a.width)
254+ for i := range a.birthOffsets {
255+ a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
256+ }
257+
258+ return a
259+}
260+
261+// SetLabel updates the label text and re-renders it.
262+func (a *Anim) SetLabel(newLabel string) {
263+ a.labelWidth = lipgloss.Width(newLabel)
264+
265+ // Update total width
266+ a.width = a.cyclingCharWidth
267+ if newLabel != "" {
268+ a.width += labelGapWidth + a.labelWidth
269+ }
270+
271+ // Re-render the label
272+ a.renderLabel(newLabel)
273+}
274+
275+// renderLabel renders the label with the current label color.
276+func (a *Anim) renderLabel(label string) {
277+ if a.labelWidth > 0 {
278+ // Pre-render the label.
279+ labelRunes := []rune(label)
280+ a.label = csync.NewSlice[string]()
281+ for i := range labelRunes {
282+ rendered := lipgloss.NewStyle().
283+ Foreground(a.labelColor).
284+ Render(string(labelRunes[i]))
285+ a.label.Append(rendered)
286+ }
287+
288+ // Pre-render the ellipsis frames which come after the label.
289+ a.ellipsisFrames = csync.NewSlice[string]()
290+ for _, frame := range ellipsisFrames {
291+ rendered := lipgloss.NewStyle().
292+ Foreground(a.labelColor).
293+ Render(frame)
294+ a.ellipsisFrames.Append(rendered)
295+ }
296+ } else {
297+ a.label = csync.NewSlice[string]()
298+ a.ellipsisFrames = csync.NewSlice[string]()
299+ }
300+}
301+
302+// Width returns the total width of the animation.
303+func (a *Anim) Width() (w int) {
304+ w = a.width
305+ if a.labelWidth > 0 {
306+ w += labelGapWidth + a.labelWidth
307+
308+ var widestEllipsisFrame int
309+ for _, f := range ellipsisFrames {
310+ fw := lipgloss.Width(f)
311+ if fw > widestEllipsisFrame {
312+ widestEllipsisFrame = fw
313+ }
314+ }
315+ w += widestEllipsisFrame
316+ }
317+ return w
318+}
319+
320+// Start starts the animation.
321+func (a *Anim) Start() tea.Cmd {
322+ return a.Step()
323+}
324+
325+// Animate advances the animation to the next step.
326+func (a *Anim) Animate(msg StepMsg) tea.Cmd {
327+ if msg.ID != a.id {
328+ return nil
329+ }
330+
331+ step := a.step.Add(1)
332+ if int(step) >= len(a.cyclingFrames) {
333+ a.step.Store(0)
334+ }
335+
336+ if a.initialized.Load() && a.labelWidth > 0 {
337+ // Manage the ellipsis animation.
338+ ellipsisStep := a.ellipsisStep.Add(1)
339+ if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
340+ a.ellipsisStep.Store(0)
341+ }
342+ } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
343+ a.initialized.Store(true)
344+ }
345+ return a.Step()
346+}
347+
348+// Render renders the current state of the animation.
349+func (a *Anim) Render() string {
350+ var b strings.Builder
351+ step := int(a.step.Load())
352+ for i := range a.width {
353+ switch {
354+ case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
355+ // Birth offset not reached: render initial character.
356+ b.WriteString(a.initialFrames[step][i])
357+ case i < a.cyclingCharWidth:
358+ // Render a cycling character.
359+ b.WriteString(a.cyclingFrames[step][i])
360+ case i == a.cyclingCharWidth:
361+ // Render label gap.
362+ b.WriteString(labelGap)
363+ case i > a.cyclingCharWidth:
364+ // Label.
365+ if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
366+ b.WriteString(labelChar)
367+ }
368+ }
369+ }
370+ // Render animated ellipsis at the end of the label if all characters
371+ // have been initialized.
372+ if a.initialized.Load() && a.labelWidth > 0 {
373+ ellipsisStep := int(a.ellipsisStep.Load())
374+ if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
375+ b.WriteString(ellipsisFrame)
376+ }
377+ }
378+
379+ return b.String()
380+}
381+
382+// Step is a command that triggers the next step in the animation.
383+func (a *Anim) Step() tea.Cmd {
384+ return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
385+ return StepMsg{ID: a.id}
386+ })
387+}
388+
389+// makeGradientRamp() returns a slice of colors blended between the given keys.
390+// Blending is done as Hcl to stay in gamut.
391+func makeGradientRamp(size int, stops ...color.Color) []color.Color {
392+ if len(stops) < 2 {
393+ return nil
394+ }
395+
396+ points := make([]colorful.Color, len(stops))
397+ for i, k := range stops {
398+ points[i], _ = colorful.MakeColor(k)
399+ }
400+
401+ numSegments := len(stops) - 1
402+ if numSegments == 0 {
403+ return nil
404+ }
405+ blended := make([]color.Color, 0, size)
406+
407+ // Calculate how many colors each segment should have.
408+ segmentSizes := make([]int, numSegments)
409+ baseSize := size / numSegments
410+ remainder := size % numSegments
411+
412+ // Distribute the remainder across segments.
413+ for i := range numSegments {
414+ segmentSizes[i] = baseSize
415+ if i < remainder {
416+ segmentSizes[i]++
417+ }
418+ }
419+
420+ // Generate colors for each segment.
421+ for i := range numSegments {
422+ c1 := points[i]
423+ c2 := points[i+1]
424+ segmentSize := segmentSizes[i]
425+
426+ for j := range segmentSize {
427+ if segmentSize == 0 {
428+ continue
429+ }
430+ t := float64(j) / float64(segmentSize)
431+ c := c1.BlendHcl(c2, t)
432+ blended = append(blended, c)
433+ }
434+ }
435+
436+ return blended
437+}
438+
439+func colorIsUnset(c color.Color) bool {
440+ if c == nil {
441+ return true
442+ }
443+ _, _, _, a := c.RGBA()
444+ return a == 0
445+}
internal/ui/chat/assistant.gonew file+251
@@ -0,0 +1,251 @@
1+package chat
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ tea "charm.land/bubbletea/v2"
8+ "charm.land/lipgloss/v2"
9+ "github.com/charmbracelet/crush/internal/message"
10+ "github.com/charmbracelet/crush/internal/ui/anim"
11+ "github.com/charmbracelet/crush/internal/ui/common"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// maxCollapsedThinkingHeight defines the maximum height of the thinking
17+const maxCollapsedThinkingHeight = 10
18+
19+// AssistantMessageItem represents an assistant message in the chat UI.
20+//
21+// This item includes thinking, and the content but does not include the tool calls.
22+type AssistantMessageItem struct {
23+ *highlightableMessageItem
24+ *cachedMessageItem
25+ *focusableMessageItem
26+
27+ message *message.Message
28+ sty *styles.Styles
29+ anim *anim.Anim
30+ thinkingExpanded bool
31+ thinkingBoxHeight int // Tracks the rendered thinking box height for click detection.
32+}
33+
34+// NewAssistantMessageItem creates a new AssistantMessageItem.
35+func NewAssistantMessageItem(sty *styles.Styles, message *message.Message) MessageItem {
36+ a := &AssistantMessageItem{
37+ highlightableMessageItem: defaultHighlighter(sty),
38+ cachedMessageItem: &cachedMessageItem{},
39+ focusableMessageItem: &focusableMessageItem{},
40+ message: message,
41+ sty: sty,
42+ }
43+
44+ a.anim = anim.New(anim.Settings{
45+ ID: a.ID(),
46+ Size: 15,
47+ GradColorA: sty.Primary,
48+ GradColorB: sty.Secondary,
49+ LabelColor: sty.FgBase,
50+ CycleColors: true,
51+ })
52+ return a
53+}
54+
55+// StartAnimation starts the assistant message animation if it should be spinning.
56+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
57+ if !a.isSpinning() {
58+ return nil
59+ }
60+ return a.anim.Start()
61+}
62+
63+// Animate progresses the assistant message animation if it should be spinning.
64+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
65+ if !a.isSpinning() {
66+ return nil
67+ }
68+ return a.anim.Animate(msg)
69+}
70+
71+// ID implements MessageItem.
72+func (a *AssistantMessageItem) ID() string {
73+ return a.message.ID
74+}
75+
76+// Render implements MessageItem.
77+func (a *AssistantMessageItem) Render(width int) string {
78+ cappedWidth := cappedMessageWidth(width)
79+ style := a.sty.Chat.Message.AssistantBlurred
80+ if a.focused {
81+ style = a.sty.Chat.Message.AssistantFocused
82+ }
83+
84+ var spinner string
85+ if a.isSpinning() {
86+ spinner = a.renderSpinning()
87+ }
88+
89+ content, height, ok := a.getCachedRender(cappedWidth)
90+ if !ok {
91+ content = a.renderMessageContent(cappedWidth)
92+ height = lipgloss.Height(content)
93+ // cache the rendered content
94+ a.setCachedRender(content, cappedWidth, height)
95+ }
96+
97+ highlightedContent := a.renderHighlighted(content, cappedWidth, height)
98+ if spinner != "" {
99+ if highlightedContent != "" {
100+ highlightedContent += "\n\n"
101+ }
102+ return style.Render(highlightedContent + spinner)
103+ }
104+
105+ return style.Render(highlightedContent)
106+}
107+
108+// renderMessageContent renders the message content including thinking, main content, and finish reason.
109+func (a *AssistantMessageItem) renderMessageContent(width int) string {
110+ var messageParts []string
111+ thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
112+ content := strings.TrimSpace(a.message.Content().Text)
113+ // if the massage has reasoning content add that first
114+ if thinking != "" {
115+ messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
116+ }
117+
118+ // then add the main content
119+ if content != "" {
120+ // add a spacer between thinking and content
121+ if thinking != "" {
122+ messageParts = append(messageParts, "")
123+ }
124+ messageParts = append(messageParts, a.renderMarkdown(content, width))
125+ }
126+
127+ // finally add any finish reason info
128+ if a.message.IsFinished() {
129+ switch a.message.FinishReason() {
130+ case message.FinishReasonCanceled:
131+ messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
132+ case message.FinishReasonError:
133+ messageParts = append(messageParts, a.renderError(width))
134+ }
135+ }
136+
137+ return strings.Join(messageParts, "\n")
138+}
139+
140+// renderThinking renders the thinking/reasoning content with footer.
141+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
142+ renderer := common.PlainMarkdownRenderer(a.sty, width)
143+ rendered, err := renderer.Render(thinking)
144+ if err != nil {
145+ rendered = thinking
146+ }
147+ rendered = strings.TrimSpace(rendered)
148+
149+ lines := strings.Split(rendered, "\n")
150+ totalLines := len(lines)
151+
152+ isTruncated := totalLines > maxCollapsedThinkingHeight
153+ if !a.thinkingExpanded && isTruncated {
154+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
155+ }
156+
157+ if !a.thinkingExpanded && isTruncated {
158+ hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
159+ fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
160+ )
161+ lines = append([]string{hint}, lines...)
162+ }
163+
164+ thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
165+ result := thinkingStyle.Render(strings.Join(lines, "\n"))
166+ a.thinkingBoxHeight = lipgloss.Height(result)
167+
168+ var footer string
169+ // if thinking is done add the thought for footer
170+ if !a.message.IsThinking() {
171+ duration := a.message.ThinkingDuration()
172+ if duration.String() != "0s" {
173+ footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
174+ a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
175+ }
176+ }
177+
178+ if footer != "" {
179+ result += "\n\n" + footer
180+ }
181+
182+ return result
183+}
184+
185+// renderMarkdown renders content as markdown.
186+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
187+ renderer := common.MarkdownRenderer(a.sty, width)
188+ result, err := renderer.Render(content)
189+ if err != nil {
190+ return content
191+ }
192+ return strings.TrimSuffix(result, "\n")
193+}
194+
195+func (a *AssistantMessageItem) renderSpinning() string {
196+ if a.message.IsThinking() {
197+ a.anim.SetLabel("Thinking")
198+ } else if a.message.IsSummaryMessage {
199+ a.anim.SetLabel("Summarizing")
200+ }
201+ return a.anim.Render()
202+}
203+
204+// renderError renders an error message.
205+func (a *AssistantMessageItem) renderError(width int) string {
206+ finishPart := a.message.FinishPart()
207+ errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
208+ truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
209+ title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
210+ details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
211+ return fmt.Sprintf("%s\n\n%s", title, details)
212+}
213+
214+// isSpinning returns true if the assistant message is still generating.
215+func (a *AssistantMessageItem) isSpinning() bool {
216+ isThinking := a.message.IsThinking()
217+ isFinished := a.message.IsFinished()
218+ hasContent := strings.TrimSpace(a.message.Content().Text) != ""
219+ hasToolCalls := len(a.message.ToolCalls()) > 0
220+ return (isThinking || !isFinished) && !hasContent && !hasToolCalls
221+}
222+
223+// SetMessage is used to update the underlying message.
224+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
225+ wasSpinning := a.isSpinning()
226+ a.message = message
227+ a.clearCache()
228+ if !wasSpinning && a.isSpinning() {
229+ return a.StartAnimation()
230+ }
231+ return nil
232+}
233+
234+// ToggleExpanded toggles the expanded state of the thinking box.
235+func (a *AssistantMessageItem) ToggleExpanded() {
236+ a.thinkingExpanded = !a.thinkingExpanded
237+ a.clearCache()
238+}
239+
240+// HandleMouseClick implements MouseClickable.
241+func (a *AssistantMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
242+ if btn != ansi.MouseLeft {
243+ return false
244+ }
245+ // check if the click is within the thinking box
246+ if a.thinkingBoxHeight > 0 && y < a.thinkingBoxHeight {
247+ a.ToggleExpanded()
248+ return true
249+ }
250+ return false
251+}
internal/ui/chat/messages.go+54−1
@@ -1,9 +1,15 @@
1+// Package chat provides UI components for displaying and managing chat messages.
2+// It defines message item types that can be rendered in a list view, including
3+// support for highlighting, focusing, and caching rendered content.
1package chat
4package chat
2
5
3import (
6import (
4 "image"
7 "image"
8+ "strings"
5
9
10+ tea "charm.land/bubbletea/v2"
6 "github.com/charmbracelet/crush/internal/message"
11 "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/ui/anim"
7 "github.com/charmbracelet/crush/internal/ui/list"
13 "github.com/charmbracelet/crush/internal/ui/list"
8 "github.com/charmbracelet/crush/internal/ui/styles"
14 "github.com/charmbracelet/crush/internal/ui/styles"
9)
15)
@@ -20,6 +26,17 @@ type Identifiable interface {
20 ID() string
26 ID() string
21}
27}
22
28
29+// Animatable is an interface for items that support animation.
30+type Animatable interface {
31+ StartAnimation() tea.Cmd
32+ Animate(msg anim.StepMsg) tea.Cmd
33+}
34+
35+// Expandable is an interface for items that can be expanded or collapsed.
36+type Expandable interface {
37+ ToggleExpanded()
38+}
39+
23// MessageItem represents a [message.Message] item that can be displayed in the
40// MessageItem represents a [message.Message] item that can be displayed in the
24// UI and be part of a [list.List] identifiable by a unique ID.
41// UI and be part of a [list.List] identifiable by a unique ID.
25type MessageItem interface {
42type MessageItem interface {
@@ -84,7 +101,7 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
84
101
85// cachedMessageItem caches rendered message content to avoid re-rendering.
102// cachedMessageItem caches rendered message content to avoid re-rendering.
86//
103//
87−// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
104+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
88//
105//
89// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
106// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
90// the issue with that could be memory usage
107// the issue with that could be memory usage
@@ -111,6 +128,23 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
111 c.height = height
128 c.height = height
112}
129}
113
130
131+// clearCache clears the cached render.
132+func (c *cachedMessageItem) clearCache() {
133+ c.rendered = ""
134+ c.width = 0
135+ c.height = 0
136+}
137+
138+// focusableMessageItem is a base struct for message items that can be focused.
139+type focusableMessageItem struct {
140+ focused bool
141+}
142+
143+// SetFocused implements MessageItem.
144+func (f *focusableMessageItem) SetFocused(focused bool) {
145+ f.focused = focused
146+}
147+
114// cappedMessageWidth returns the maximum width for message content for readability.
148// cappedMessageWidth returns the maximum width for message content for readability.
115func cappedMessageWidth(availableWidth int) int {
149func cappedMessageWidth(availableWidth int) int {
116 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
150 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
@@ -125,10 +159,29 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
125 switch msg.Role {
159 switch msg.Role {
126 case message.User:
160 case message.User:
127 return []MessageItem{NewUserMessageItem(sty, msg)}
161 return []MessageItem{NewUserMessageItem(sty, msg)}
162+ case message.Assistant:
163+ var items []MessageItem
164+ if shouldRenderAssistantMessage(msg) {
165+ items = append(items, NewAssistantMessageItem(sty, msg))
166+ }
167+ return items
128 }
168 }
129 return []MessageItem{}
169 return []MessageItem{}
130}
170}
131
171
172+// shouldRenderAssistantMessage determines if an assistant message should be rendered
173+//
174+// In some cases the assistant message only has tools so we do not want to render an
175+// empty message.
176+func shouldRenderAssistantMessage(msg *message.Message) bool {
177+ content := strings.TrimSpace(msg.Content().Text)
178+ thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
179+ isError := msg.FinishReason() == message.FinishReasonError
180+ isCancelled := msg.FinishReason() == message.FinishReasonCanceled
181+ hasToolCalls := len(msg.ToolCalls()) > 0
182+ return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
183+}
184+
132// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
185// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
133// Tool result messages (role == message.Tool) contain the results that should be linked
186// Tool result messages (role == message.Tool) contain the results that should be linked
134// to tool calls in assistant messages.
187// to tool calls in assistant messages.
internal/ui/chat/user.go+3−7
@@ -16,9 +16,10 @@ import (
16type UserMessageItem struct {
16type UserMessageItem struct {
17 *highlightableMessageItem
17 *highlightableMessageItem
18 *cachedMessageItem
18 *cachedMessageItem
19+ *focusableMessageItem
20+
19 message *message.Message
21 message *message.Message
20 sty *styles.Styles
22 sty *styles.Styles
21− focused bool
22}
23}
23
24
24// NewUserMessageItem creates a new UserMessageItem.
25// NewUserMessageItem creates a new UserMessageItem.
@@ -26,9 +27,9 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageIte
26 return &UserMessageItem{
27 return &UserMessageItem{
27 highlightableMessageItem: defaultHighlighter(sty),
28 highlightableMessageItem: defaultHighlighter(sty),
28 cachedMessageItem: &cachedMessageItem{},
29 cachedMessageItem: &cachedMessageItem{},
30+ focusableMessageItem: &focusableMessageItem{},
29 message: message,
31 message: message,
30 sty: sty,
32 sty: sty,
31− focused: false,
32 }
33 }
33}
34}
34
35
@@ -67,11 +68,6 @@ func (m *UserMessageItem) Render(width int) string {
67 return style.Render(m.renderHighlighted(content, cappedWidth, height))
68 return style.Render(m.renderHighlighted(content, cappedWidth, height))
68}
69}
69
70
70−// SetFocused implements MessageItem.
71−func (m *UserMessageItem) SetFocused(focused bool) {
72− m.focused = focused
73−}
74−
75// ID implements MessageItem.
71// ID implements MessageItem.
76func (m *UserMessageItem) ID() string {
72func (m *UserMessageItem) ID() string {
77 return m.message.ID
73 return m.message.ID
internal/ui/common/markdown.go+4−5
@@ -2,15 +2,14 @@ package common
2
2
3import (
3import (
4 "charm.land/glamour/v2"
4 "charm.land/glamour/v2"
5− gstyles "charm.land/glamour/v2/styles"
6 "github.com/charmbracelet/crush/internal/ui/styles"
5 "github.com/charmbracelet/crush/internal/ui/styles"
7)
6)
8
7
9// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
8// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
10// the given styles and width.
9// the given styles and width.
11−func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
10+func MarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
12 r, _ := glamour.NewTermRenderer(
11 r, _ := glamour.NewTermRenderer(
13− glamour.WithStyles(t.Markdown),
12+ glamour.WithStyles(sty.Markdown),
14 glamour.WithWordWrap(width),
13 glamour.WithWordWrap(width),
15 )
14 )
16 return r
15 return r
@@ -18,9 +17,9 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
18
17
19// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
18// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
20// (plain text with structure) and the given width.
19// (plain text with structure) and the given width.
21−func PlainMarkdownRenderer(width int) *glamour.TermRenderer {
20+func PlainMarkdownRenderer(sty *styles.Styles, width int) *glamour.TermRenderer {
22 r, _ := glamour.NewTermRenderer(
21 r, _ := glamour.NewTermRenderer(
23− glamour.WithStyles(gstyles.ASCIIStyleConfig),
22+ glamour.WithStyles(sty.PlainMarkdown),
24 glamour.WithWordWrap(width),
23 glamour.WithWordWrap(width),
25 )
24 )
26 return r
25 return r
internal/ui/list/list.go+6−6
@@ -189,9 +189,9 @@ func (l *List) ScrollBy(lines int) {
189 }
189 }
190}
190}
191
191
192−// findVisibleItems finds the range of items that are visible in the viewport.
192+// VisibleItemIndices finds the range of items that are visible in the viewport.
193// This is used for checking if selected item is in view.
193// This is used for checking if selected item is in view.
194−func (l *List) findVisibleItems() (startIdx, endIdx int) {
194+func (l *List) VisibleItemIndices() (startIdx, endIdx int) {
195 if len(l.items) == 0 {
195 if len(l.items) == 0 {
196 return 0, 0
196 return 0, 0
197 }
197 }
@@ -352,7 +352,7 @@ func (l *List) ScrollToSelected() {
352 return
352 return
353 }
353 }
354
354
355− startIdx, endIdx := l.findVisibleItems()
355+ startIdx, endIdx := l.VisibleItemIndices()
356 if l.selectedIdx < startIdx {
356 if l.selectedIdx < startIdx {
357 // Selected item is above the visible range
357 // Selected item is above the visible range
358 l.offsetIdx = l.selectedIdx
358 l.offsetIdx = l.selectedIdx
@@ -385,7 +385,7 @@ func (l *List) SelectedItemInView() bool {
385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
385 if l.selectedIdx < 0 || l.selectedIdx >= len(l.items) {
386 return false
386 return false
387 }
387 }
388− startIdx, endIdx := l.findVisibleItems()
388+ startIdx, endIdx := l.VisibleItemIndices()
389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
389 return l.selectedIdx >= startIdx && l.selectedIdx <= endIdx
390}
390}
391
391
@@ -466,13 +466,13 @@ func (l *List) SelectedItem() Item {
466
466
467// SelectFirstInView selects the first item currently in view.
467// SelectFirstInView selects the first item currently in view.
468func (l *List) SelectFirstInView() {
468func (l *List) SelectFirstInView() {
469− startIdx, _ := l.findVisibleItems()
469+ startIdx, _ := l.VisibleItemIndices()
470 l.selectedIdx = startIdx
470 l.selectedIdx = startIdx
471}
471}
472
472
473// SelectLastInView selects the last item currently in view.
473// SelectLastInView selects the last item currently in view.
474func (l *List) SelectLastInView() {
474func (l *List) SelectLastInView() {
475− _, endIdx := l.findVisibleItems()
475+ _, endIdx := l.VisibleItemIndices()
476 l.selectedIdx = endIdx
476 l.selectedIdx = endIdx
477}
477}
478
478
internal/ui/model/chat.go+120−21
@@ -1,6 +1,8 @@
1package model
1package model
2
2
3import (
3import (
4+ tea "charm.land/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/ui/anim"
4 "github.com/charmbracelet/crush/internal/ui/chat"
6 "github.com/charmbracelet/crush/internal/ui/chat"
5 "github.com/charmbracelet/crush/internal/ui/common"
7 "github.com/charmbracelet/crush/internal/ui/common"
6 "github.com/charmbracelet/crush/internal/ui/list"
8 "github.com/charmbracelet/crush/internal/ui/list"
@@ -11,8 +13,14 @@ import (
11// Chat represents the chat UI model that handles chat interactions and
13// Chat represents the chat UI model that handles chat interactions and
12// messages.
14// messages.
13type Chat struct {
15type Chat struct {
14− com *common.Common
16+ com *common.Common
15− list *list.List
17+ list *list.List
18+ idInxMap map[string]int // Map of message IDs to their indices in the list
19+
20+ // Animation visibility optimization: track animations paused due to items
21+ // being scrolled out of view. When items become visible again, their
22+ // animations are restarted.
23+ pausedAnimations map[string]struct{}
16
24
17 // Mouse state
25 // Mouse state
18 mouseDown bool
26 mouseDown bool
@@ -27,7 +35,11 @@ type Chat struct {
27// NewChat creates a new instance of [Chat] that handles chat interactions and
35// NewChat creates a new instance of [Chat] that handles chat interactions and
28// messages.
36// messages.
29func NewChat(com *common.Common) *Chat {
37func NewChat(com *common.Common) *Chat {
38+ c := &Chat{
30− c := &Chat{com: com}
39+ com: com,
40+ idInxMap: make(map[string]int),
41+ pausedAnimations: make(map[string]struct{}),
42+ }
31 l := list.NewList()
43 l := list.NewList()
32 l.SetGap(1)
44 l.SetGap(1)
33 l.RegisterRenderCallback(c.applyHighlightRange)
45 l.RegisterRenderCallback(c.applyHighlightRange)
@@ -57,16 +69,14 @@ func (m *Chat) Len() int {
57 return m.list.Len()
69 return m.list.Len()
58}
70}
59
71
60−// PrependItems prepends new items to the chat list.
61−func (m *Chat) PrependItems(items ...list.Item) {
62− m.list.PrependItems(items...)
63− m.list.ScrollToIndex(0)
64−}
65−
66// SetMessages sets the chat messages to the provided list of message items.
72// SetMessages sets the chat messages to the provided list of message items.
67func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
73func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
74+ m.idInxMap = make(map[string]int)
75+ m.pausedAnimations = make(map[string]struct{})
76+
68 items := make([]list.Item, len(msgs))
77 items := make([]list.Item, len(msgs))
69 for i, msg := range msgs {
78 for i, msg := range msgs {
79+ m.idInxMap[msg.ID()] = i
70 items[i] = msg
80 items[i] = msg
71 }
81 }
72 m.list.SetItems(items...)
82 m.list.SetItems(items...)
@@ -76,16 +86,77 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
76// AppendMessages appends a new message item to the chat list.
86// AppendMessages appends a new message item to the chat list.
77func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
87func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
78 items := make([]list.Item, len(msgs))
88 items := make([]list.Item, len(msgs))
89+ indexOffset := m.list.Len()
79 for i, msg := range msgs {
90 for i, msg := range msgs {
91+ m.idInxMap[msg.ID()] = indexOffset + i
80 items[i] = msg
92 items[i] = msg
81 }
93 }
82 m.list.AppendItems(items...)
94 m.list.AppendItems(items...)
83}
95}
84
96
85−// AppendItems appends new items to the chat list.
97+// Animate animates items in the chat list. Only propagates animation messages
86−func (m *Chat) AppendItems(items ...list.Item) {
98+// to visible items to save CPU. When items are not visible, their animation ID
87− m.list.AppendItems(items...)
99+// is tracked so it can be restarted when they become visible again.
88− m.list.ScrollToIndex(m.list.Len() - 1)
100+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
101+ idx, ok := m.idInxMap[msg.ID]
102+ if !ok {
103+ return nil
104+ }
105+
106+ animatable, ok := m.list.ItemAt(idx).(chat.Animatable)
107+ if !ok {
108+ return nil
109+ }
110+
111+ // Check if item is currently visible.
112+ startIdx, endIdx := m.list.VisibleItemIndices()
113+ isVisible := idx >= startIdx && idx <= endIdx
114+
115+ if !isVisible {
116+ // Item not visible - pause animation by not propagating.
117+ // Track it so we can restart when it becomes visible.
118+ m.pausedAnimations[msg.ID] = struct{}{}
119+ return nil
120+ }
121+
122+ // Item is visible - remove from paused set and animate.
123+ delete(m.pausedAnimations, msg.ID)
124+ return animatable.Animate(msg)
125+}
126+
127+// RestartPausedVisibleAnimations restarts animations for items that were paused
128+// due to being scrolled out of view but are now visible again.
129+func (m *Chat) RestartPausedVisibleAnimations() tea.Cmd {
130+ if len(m.pausedAnimations) == 0 {
131+ return nil
132+ }
133+
134+ startIdx, endIdx := m.list.VisibleItemIndices()
135+ var cmds []tea.Cmd
136+
137+ for id := range m.pausedAnimations {
138+ idx, ok := m.idInxMap[id]
139+ if !ok {
140+ // Item no longer exists.
141+ delete(m.pausedAnimations, id)
142+ continue
143+ }
144+
145+ if idx >= startIdx && idx <= endIdx {
146+ // Item is now visible - restart its animation.
147+ if animatable, ok := m.list.ItemAt(idx).(chat.Animatable); ok {
148+ if cmd := animatable.StartAnimation(); cmd != nil {
149+ cmds = append(cmds, cmd)
150+ }
151+ }
152+ delete(m.pausedAnimations, id)
153+ }
154+ }
155+
156+ if len(cmds) == 0 {
157+ return nil
158+ }
159+ return tea.Batch(cmds...)
89}
160}
90
161
91// Focus sets the focus state of the chat component.
162// Focus sets the focus state of the chat component.
@@ -98,24 +169,32 @@ func (m *Chat) Blur() {
98 m.list.Blur()
169 m.list.Blur()
99}
170}
100
171
101−// ScrollToTop scrolls the chat view to the top.
172+// ScrollToTopAndAnimate scrolls the chat view to the top and returns a command to restart
102−func (m *Chat) ScrollToTop() {
173+// any paused animations that are now visible.
174+func (m *Chat) ScrollToTopAndAnimate() tea.Cmd {
103 m.list.ScrollToTop()
175 m.list.ScrollToTop()
176+ return m.RestartPausedVisibleAnimations()
104}
177}
105
178
106−// ScrollToBottom scrolls the chat view to the bottom.
179+// ScrollToBottomAndAnimate scrolls the chat view to the bottom and returns a command to
107−func (m *Chat) ScrollToBottom() {
180+// restart any paused animations that are now visible.
181+func (m *Chat) ScrollToBottomAndAnimate() tea.Cmd {
108 m.list.ScrollToBottom()
182 m.list.ScrollToBottom()
183+ return m.RestartPausedVisibleAnimations()
109}
184}
110
185
111−// ScrollBy scrolls the chat view by the given number of line deltas.
186+// ScrollByAndAnimate scrolls the chat view by the given number of line deltas and returns
187+// a command to restart any paused animations that are now visible.
112−func (m *Chat) ScrollBy(lines int) {
188+func (m *Chat) ScrollByAndAnimate(lines int) tea.Cmd {
113 m.list.ScrollBy(lines)
189 m.list.ScrollBy(lines)
190+ return m.RestartPausedVisibleAnimations()
114}
191}
115
192
116−// ScrollToSelected scrolls the chat view to the selected item.
193+// ScrollToSelectedAndAnimate scrolls the chat view to the selected item and returns a
117−func (m *Chat) ScrollToSelected() {
194+// command to restart any paused animations that are now visible.
195+func (m *Chat) ScrollToSelectedAndAnimate() tea.Cmd {
118 m.list.ScrollToSelected()
196 m.list.ScrollToSelected()
197+ return m.RestartPausedVisibleAnimations()
119}
198}
120
199
121// SelectedItemInView returns whether the selected item is currently in view.
200// SelectedItemInView returns whether the selected item is currently in view.
@@ -158,6 +237,26 @@ func (m *Chat) SelectLastInView() {
158 m.list.SelectLastInView()
237 m.list.SelectLastInView()
159}
238}
160
239
240+// GetMessageItem returns the message item at the given id.
241+func (m *Chat) GetMessageItem(id string) chat.MessageItem {
242+ idx, ok := m.idInxMap[id]
243+ if !ok {
244+ return nil
245+ }
246+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
247+ if !ok {
248+ return nil
249+ }
250+ return item
251+}
252+
253+// ToggleExpandedSelectedItem expands the selected message item if it is expandable.
254+func (m *Chat) ToggleExpandedSelectedItem() {
255+ if expandable, ok := m.list.SelectedItem().(chat.Expandable); ok {
256+ expandable.ToggleExpanded()
257+ }
258+}
259+
161// HandleMouseDown handles mouse down events for the chat component.
260// HandleMouseDown handles mouse down events for the chat component.
162func (m *Chat) HandleMouseDown(x, y int) bool {
261func (m *Chat) HandleMouseDown(x, y int) bool {
163 if m.list.Len() == 0 {
262 if m.list.Len() == 0 {
internal/ui/model/keys.go+5−1
@@ -37,6 +37,7 @@ type KeyMap struct {
37 End key.Binding
37 End key.Binding
38 Copy key.Binding
38 Copy key.Binding
39 ClearHighlight key.Binding
39 ClearHighlight key.Binding
40+ Expand key.Binding
40 }
41 }
41
42
42 Initialize struct {
43 Initialize struct {
@@ -205,7 +206,10 @@ func DefaultKeyMap() KeyMap {
205 key.WithKeys("esc", "alt+esc"),
206 key.WithKeys("esc", "alt+esc"),
206 key.WithHelp("esc", "clear selection"),
207 key.WithHelp("esc", "clear selection"),
207 )
208 )
208−
209+ km.Chat.Expand = key.NewBinding(
210+ key.WithKeys("space"),
211+ key.WithHelp("space", "expand/collapse"),
212+ )
209 km.Initialize.Yes = key.NewBinding(
213 km.Initialize.Yes = key.NewBinding(
210 key.WithKeys("y", "Y"),
214 key.WithKeys("y", "Y"),
211 key.WithHelp("y", "yes"),
215 key.WithHelp("y", "yes"),
internal/ui/model/ui.go+142−37
@@ -27,7 +27,7 @@ import (
27 "github.com/charmbracelet/crush/internal/pubsub"
27 "github.com/charmbracelet/crush/internal/pubsub"
28 "github.com/charmbracelet/crush/internal/session"
28 "github.com/charmbracelet/crush/internal/session"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
29 "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker"
30− "github.com/charmbracelet/crush/internal/tui/util"
30+ "github.com/charmbracelet/crush/internal/ui/anim"
31 "github.com/charmbracelet/crush/internal/ui/chat"
31 "github.com/charmbracelet/crush/internal/ui/chat"
32 "github.com/charmbracelet/crush/internal/ui/common"
32 "github.com/charmbracelet/crush/internal/ui/common"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
33 "github.com/charmbracelet/crush/internal/ui/dialog"
@@ -203,9 +203,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203 cmds = append(cmds, uiutil.ReportError(err))
203 cmds = append(cmds, uiutil.ReportError(err))
204 break
204 break
205 }
205 }
206− m.setSessionMessages(msgs)
206+ if cmd := m.setSessionMessages(msgs); cmd != nil {
207+ cmds = append(cmds, cmd)
208+ }
207
209
208 case pubsub.Event[message.Message]:
210 case pubsub.Event[message.Message]:
211+ if m.session == nil || msg.Payload.SessionID != m.session.ID {
212+ break
213+ }
214+ switch msg.Type {
215+ case pubsub.CreatedEvent:
216+ cmds = append(cmds, m.appendSessionMessage(msg.Payload))
217+ case pubsub.UpdatedEvent:
218+ cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219+ }
209 // TODO: Finish implementing me
220 // TODO: Finish implementing me
210 // cmds = append(cmds, m.setMessageEvents(msg.Payload))
221 // cmds = append(cmds, m.setMessageEvents(msg.Payload))
211 case pubsub.Event[history.File]:
222 case pubsub.Event[history.File]:
@@ -257,16 +268,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
257 switch m.state {
268 switch m.state {
258 case uiChat:
269 case uiChat:
259 if msg.Y <= 0 {
270 if msg.Y <= 0 {
260− m.chat.ScrollBy(-1)
271+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
272+ cmds = append(cmds, cmd)
273+ }
261 if !m.chat.SelectedItemInView() {
274 if !m.chat.SelectedItemInView() {
262 m.chat.SelectPrev()
275 m.chat.SelectPrev()
263− m.chat.ScrollToSelected()
276+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
277+ cmds = append(cmds, cmd)
278+ }
264 }
279 }
265 } else if msg.Y >= m.chat.Height()-1 {
280 } else if msg.Y >= m.chat.Height()-1 {
266− m.chat.ScrollBy(1)
281+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
282+ cmds = append(cmds, cmd)
283+ }
267 if !m.chat.SelectedItemInView() {
284 if !m.chat.SelectedItemInView() {
268 m.chat.SelectNext()
285 m.chat.SelectNext()
269− m.chat.ScrollToSelected()
286+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
287+ cmds = append(cmds, cmd)
288+ }
270 }
289 }
271 }
290 }
272
291
@@ -291,19 +310,33 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
291 case uiChat:
310 case uiChat:
292 switch msg.Button {
311 switch msg.Button {
293 case tea.MouseWheelUp:
312 case tea.MouseWheelUp:
294− m.chat.ScrollBy(-5)
313+ if cmd := m.chat.ScrollByAndAnimate(-5); cmd != nil {
314+ cmds = append(cmds, cmd)
315+ }
295 if !m.chat.SelectedItemInView() {
316 if !m.chat.SelectedItemInView() {
296 m.chat.SelectPrev()
317 m.chat.SelectPrev()
297− m.chat.ScrollToSelected()
318+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
319+ cmds = append(cmds, cmd)
320+ }
298 }
321 }
299 case tea.MouseWheelDown:
322 case tea.MouseWheelDown:
300− m.chat.ScrollBy(5)
323+ if cmd := m.chat.ScrollByAndAnimate(5); cmd != nil {
324+ cmds = append(cmds, cmd)
325+ }
301 if !m.chat.SelectedItemInView() {
326 if !m.chat.SelectedItemInView() {
302 m.chat.SelectNext()
327 m.chat.SelectNext()
303− m.chat.ScrollToSelected()
328+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
329+ cmds = append(cmds, cmd)
330+ }
304 }
331 }
305 }
332 }
306 }
333 }
334+ case anim.StepMsg:
335+ if m.state == uiChat {
336+ if cmd := m.chat.Animate(msg); cmd != nil {
337+ cmds = append(cmds, cmd)
338+ }
339+ }
307 case tea.KeyPressMsg:
340 case tea.KeyPressMsg:
308 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
341 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
309 cmds = append(cmds, cmd)
342 cmds = append(cmds, cmd)
@@ -336,7 +369,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
336}
369}
337
370
338// setSessionMessages sets the messages for the current session in the chat
371// setSessionMessages sets the messages for the current session in the chat
339−func (m *UI) setSessionMessages(msgs []message.Message) {
372+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
373+ var cmds []tea.Cmd
340 // Build tool result map to link tool calls with their results
374 // Build tool result map to link tool calls with their results
341 msgPtrs := make([]*message.Message, len(msgs))
375 msgPtrs := make([]*message.Message, len(msgs))
342 for i := range msgs {
376 for i := range msgs {
@@ -350,9 +384,54 @@ func (m *UI) setSessionMessages(msgs []message.Message) {
350 items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
384 items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
351 }
385 }
352
386
387+ // If the user switches between sessions while the agent is working we want
388+ // to make sure the animations are shown.
389+ for _, item := range items {
390+ if animatable, ok := item.(chat.Animatable); ok {
391+ if cmd := animatable.StartAnimation(); cmd != nil {
392+ cmds = append(cmds, cmd)
393+ }
394+ }
395+ }
396+
353 m.chat.SetMessages(items...)
397 m.chat.SetMessages(items...)
354− m.chat.ScrollToBottom()
398+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
399+ cmds = append(cmds, cmd)
400+ }
355 m.chat.SelectLast()
401 m.chat.SelectLast()
402+ return tea.Batch(cmds...)
403+}
404+
405+// appendSessionMessage appends a new message to the current session in the chat
406+func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
407+ items := chat.GetMessageItems(m.com.Styles, &msg, nil)
408+ var cmds []tea.Cmd
409+ for _, item := range items {
410+ if animatable, ok := item.(chat.Animatable); ok {
411+ if cmd := animatable.StartAnimation(); cmd != nil {
412+ cmds = append(cmds, cmd)
413+ }
414+ }
415+ }
416+ m.chat.AppendMessages(items...)
417+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
418+ cmds = append(cmds, cmd)
419+ }
420+ return tea.Batch(cmds...)
421+}
422+
423+// updateSessionMessage updates an existing message in the current session in the chat
424+// INFO: currently only updates the assistant when I add tools this will get a bit more complex
425+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
426+ existingItem := m.chat.GetMessageItem(msg.ID)
427+ switch msg.Role {
428+ case message.Assistant:
429+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
430+ assistantItem.SetMessage(&msg)
431+ }
432+ }
433+
434+ return nil
356}
435}
357
436
358func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
437func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,41 +577,67 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
498 m.focus = uiFocusEditor
577 m.focus = uiFocusEditor
499 cmds = append(cmds, m.textarea.Focus())
578 cmds = append(cmds, m.textarea.Focus())
500 m.chat.Blur()
579 m.chat.Blur()
580+ case key.Matches(msg, m.keyMap.Chat.Expand):
581+ m.chat.ToggleExpandedSelectedItem()
501 case key.Matches(msg, m.keyMap.Chat.Up):
582 case key.Matches(msg, m.keyMap.Chat.Up):
502− m.chat.ScrollBy(-1)
583+ if cmd := m.chat.ScrollByAndAnimate(-1); cmd != nil {
584+ cmds = append(cmds, cmd)
585+ }
503 if !m.chat.SelectedItemInView() {
586 if !m.chat.SelectedItemInView() {
504 m.chat.SelectPrev()
587 m.chat.SelectPrev()
505− m.chat.ScrollToSelected()
588+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
589+ cmds = append(cmds, cmd)
590+ }
506 }
591 }
507 case key.Matches(msg, m.keyMap.Chat.Down):
592 case key.Matches(msg, m.keyMap.Chat.Down):
508− m.chat.ScrollBy(1)
593+ if cmd := m.chat.ScrollByAndAnimate(1); cmd != nil {
594+ cmds = append(cmds, cmd)
595+ }
509 if !m.chat.SelectedItemInView() {
596 if !m.chat.SelectedItemInView() {
510 m.chat.SelectNext()
597 m.chat.SelectNext()
511− m.chat.ScrollToSelected()
598+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
599+ cmds = append(cmds, cmd)
600+ }
512 }
601 }
513 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
602 case key.Matches(msg, m.keyMap.Chat.UpOneItem):
514 m.chat.SelectPrev()
603 m.chat.SelectPrev()
515− m.chat.ScrollToSelected()
604+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
605+ cmds = append(cmds, cmd)
606+ }
516 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
607 case key.Matches(msg, m.keyMap.Chat.DownOneItem):
517 m.chat.SelectNext()
608 m.chat.SelectNext()
518− m.chat.ScrollToSelected()
609+ if cmd := m.chat.ScrollToSelectedAndAnimate(); cmd != nil {
610+ cmds = append(cmds, cmd)
611+ }
519 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
612 case key.Matches(msg, m.keyMap.Chat.HalfPageUp):
520− m.chat.ScrollBy(-m.chat.Height() / 2)
613+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height() / 2); cmd != nil {
614+ cmds = append(cmds, cmd)
615+ }
521 m.chat.SelectFirstInView()
616 m.chat.SelectFirstInView()
522 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
617 case key.Matches(msg, m.keyMap.Chat.HalfPageDown):
523− m.chat.ScrollBy(m.chat.Height() / 2)
618+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height() / 2); cmd != nil {
619+ cmds = append(cmds, cmd)
620+ }
524 m.chat.SelectLastInView()
621 m.chat.SelectLastInView()
525 case key.Matches(msg, m.keyMap.Chat.PageUp):
622 case key.Matches(msg, m.keyMap.Chat.PageUp):
526− m.chat.ScrollBy(-m.chat.Height())
623+ if cmd := m.chat.ScrollByAndAnimate(-m.chat.Height()); cmd != nil {
624+ cmds = append(cmds, cmd)
625+ }
527 m.chat.SelectFirstInView()
626 m.chat.SelectFirstInView()
528 case key.Matches(msg, m.keyMap.Chat.PageDown):
627 case key.Matches(msg, m.keyMap.Chat.PageDown):
529− m.chat.ScrollBy(m.chat.Height())
628+ if cmd := m.chat.ScrollByAndAnimate(m.chat.Height()); cmd != nil {
629+ cmds = append(cmds, cmd)
630+ }
530 m.chat.SelectLastInView()
631 m.chat.SelectLastInView()
531 case key.Matches(msg, m.keyMap.Chat.Home):
632 case key.Matches(msg, m.keyMap.Chat.Home):
532− m.chat.ScrollToTop()
633+ if cmd := m.chat.ScrollToTopAndAnimate(); cmd != nil {
634+ cmds = append(cmds, cmd)
635+ }
533 m.chat.SelectFirst()
636 m.chat.SelectFirst()
534 case key.Matches(msg, m.keyMap.Chat.End):
637 case key.Matches(msg, m.keyMap.Chat.End):
535− m.chat.ScrollToBottom()
638+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
639+ cmds = append(cmds, cmd)
640+ }
536 m.chat.SelectLast()
641 m.chat.SelectLast()
537 default:
642 default:
538 handleGlobalKeys(msg)
643 handleGlobalKeys(msg)
@@ -1161,33 +1266,33 @@ func (m *UI) renderSidebarLogo(width int) {
1161
1266
1162// sendMessage sends a message with the given content and attachments.
1267// sendMessage sends a message with the given content and attachments.
1163func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1268func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1164− if m.session == nil {
1269+ if m.com.App.AgentCoordinator == nil {
1165− return uiutil.ReportError(fmt.Errorf("no session selected"))
1270+ return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
1166 }
1271 }
1167− session := *m.session
1272+
1168 var cmds []tea.Cmd
1273 var cmds []tea.Cmd
1169− if m.session.ID == "" {
1274+ if m.session == nil || m.session.ID == "" {
1170 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1275 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
1171 if err != nil {
1276 if err != nil {
1172 return uiutil.ReportError(err)
1277 return uiutil.ReportError(err)
1173 }
1278 }
1279+ m.state = uiChat
1174− session = newSession
1280+ m.session = &newSession
1175− cmds = append(cmds, m.loadSession(session.ID))
1281+ cmds = append(cmds, m.loadSession(newSession.ID))
1176− }
1177− if m.com.App.AgentCoordinator == nil {
1178− return util.ReportError(fmt.Errorf("coder agent is not initialized"))
1179 }
1282 }
1180− m.chat.ScrollToBottom()
1283+
1284+ // Capture session ID to avoid race with main goroutine updating m.session.
1285+ sessionID := m.session.ID
1181 cmds = append(cmds, func() tea.Msg {
1286 cmds = append(cmds, func() tea.Msg {
1182− _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
1287+ _, err := m.com.App.AgentCoordinator.Run(context.Background(), sessionID, content, attachments...)
1183 if err != nil {
1288 if err != nil {
1184 isCancelErr := errors.Is(err, context.Canceled)
1289 isCancelErr := errors.Is(err, context.Canceled)
1185 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1290 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
1186 if isCancelErr || isPermissionErr {
1291 if isCancelErr || isPermissionErr {
1187 return nil
1292 return nil
1188 }
1293 }
1189− return util.InfoMsg{
1294+ return uiutil.InfoMsg{
1190− Type: util.InfoTypeError,
1295+ Type: uiutil.InfoTypeError,
1191 Msg: err.Error(),
1296 Msg: err.Error(),
1192 }
1297 }
1193 }
1298 }
internal/ui/styles/styles.go+177−3
@@ -85,7 +85,8 @@ type Styles struct {
85 ItemOnlineIcon lipgloss.Style
85 ItemOnlineIcon lipgloss.Style
86
86
87 // Markdown & Chroma
87 // Markdown & Chroma
88+ Markdown ansi.StyleConfig
88− Markdown ansi.StyleConfig
89+ PlainMarkdown ansi.StyleConfig
89
90
90 // Inputs
91 // Inputs
91 TextInput textinput.Styles
92 TextInput textinput.Styles
@@ -195,8 +196,13 @@ type Styles struct {
195 Attachment lipgloss.Style
196 Attachment lipgloss.Style
196 ToolCallFocused lipgloss.Style
197 ToolCallFocused lipgloss.Style
197 ToolCallBlurred lipgloss.Style
198 ToolCallBlurred lipgloss.Style
198− ThinkingFooter lipgloss.Style
199 SectionHeader lipgloss.Style
199 SectionHeader lipgloss.Style
200+
201+ // Thinking section styles
202+ ThinkingBox lipgloss.Style // Background for thinking content
203+ ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
204+ ThinkingFooterTitle lipgloss.Style // "Thought for" text
205+ ThinkingFooterDuration lipgloss.Style // Duration value
200 }
206 }
201 }
207 }
202
208
@@ -667,6 +673,169 @@ func DefaultStyles() Styles {
667 },
673 },
668 }
674 }
669
675
676+ // PlainMarkdown style - muted colors on subtle background for thinking content.
677+ plainBg := stringPtr(bgBaseLighter.Hex())
678+ plainFg := stringPtr(fgMuted.Hex())
679+ s.PlainMarkdown = ansi.StyleConfig{
680+ Document: ansi.StyleBlock{
681+ StylePrimitive: ansi.StylePrimitive{
682+ Color: plainFg,
683+ BackgroundColor: plainBg,
684+ },
685+ },
686+ BlockQuote: ansi.StyleBlock{
687+ StylePrimitive: ansi.StylePrimitive{
688+ Color: plainFg,
689+ BackgroundColor: plainBg,
690+ },
691+ Indent: uintPtr(1),
692+ IndentToken: stringPtr("│ "),
693+ },
694+ List: ansi.StyleList{
695+ LevelIndent: defaultListIndent,
696+ },
697+ Heading: ansi.StyleBlock{
698+ StylePrimitive: ansi.StylePrimitive{
699+ BlockSuffix: "\n",
700+ Bold: boolPtr(true),
701+ Color: plainFg,
702+ BackgroundColor: plainBg,
703+ },
704+ },
705+ H1: ansi.StyleBlock{
706+ StylePrimitive: ansi.StylePrimitive{
707+ Prefix: " ",
708+ Suffix: " ",
709+ Bold: boolPtr(true),
710+ Color: plainFg,
711+ BackgroundColor: plainBg,
712+ },
713+ },
714+ H2: ansi.StyleBlock{
715+ StylePrimitive: ansi.StylePrimitive{
716+ Prefix: "## ",
717+ Color: plainFg,
718+ BackgroundColor: plainBg,
719+ },
720+ },
721+ H3: ansi.StyleBlock{
722+ StylePrimitive: ansi.StylePrimitive{
723+ Prefix: "### ",
724+ Color: plainFg,
725+ BackgroundColor: plainBg,
726+ },
727+ },
728+ H4: ansi.StyleBlock{
729+ StylePrimitive: ansi.StylePrimitive{
730+ Prefix: "#### ",
731+ Color: plainFg,
732+ BackgroundColor: plainBg,
733+ },
734+ },
735+ H5: ansi.StyleBlock{
736+ StylePrimitive: ansi.StylePrimitive{
737+ Prefix: "##### ",
738+ Color: plainFg,
739+ BackgroundColor: plainBg,
740+ },
741+ },
742+ H6: ansi.StyleBlock{
743+ StylePrimitive: ansi.StylePrimitive{
744+ Prefix: "###### ",
745+ Color: plainFg,
746+ BackgroundColor: plainBg,
747+ },
748+ },
749+ Strikethrough: ansi.StylePrimitive{
750+ CrossedOut: boolPtr(true),
751+ Color: plainFg,
752+ BackgroundColor: plainBg,
753+ },
754+ Emph: ansi.StylePrimitive{
755+ Italic: boolPtr(true),
756+ Color: plainFg,
757+ BackgroundColor: plainBg,
758+ },
759+ Strong: ansi.StylePrimitive{
760+ Bold: boolPtr(true),
761+ Color: plainFg,
762+ BackgroundColor: plainBg,
763+ },
764+ HorizontalRule: ansi.StylePrimitive{
765+ Format: "\n--------\n",
766+ Color: plainFg,
767+ BackgroundColor: plainBg,
768+ },
769+ Item: ansi.StylePrimitive{
770+ BlockPrefix: "• ",
771+ Color: plainFg,
772+ BackgroundColor: plainBg,
773+ },
774+ Enumeration: ansi.StylePrimitive{
775+ BlockPrefix: ". ",
776+ Color: plainFg,
777+ BackgroundColor: plainBg,
778+ },
779+ Task: ansi.StyleTask{
780+ StylePrimitive: ansi.StylePrimitive{
781+ Color: plainFg,
782+ BackgroundColor: plainBg,
783+ },
784+ Ticked: "[✓] ",
785+ Unticked: "[ ] ",
786+ },
787+ Link: ansi.StylePrimitive{
788+ Underline: boolPtr(true),
789+ Color: plainFg,
790+ BackgroundColor: plainBg,
791+ },
792+ LinkText: ansi.StylePrimitive{
793+ Bold: boolPtr(true),
794+ Color: plainFg,
795+ BackgroundColor: plainBg,
796+ },
797+ Image: ansi.StylePrimitive{
798+ Underline: boolPtr(true),
799+ Color: plainFg,
800+ BackgroundColor: plainBg,
801+ },
802+ ImageText: ansi.StylePrimitive{
803+ Format: "Image: {{.text}} →",
804+ Color: plainFg,
805+ BackgroundColor: plainBg,
806+ },
807+ Code: ansi.StyleBlock{
808+ StylePrimitive: ansi.StylePrimitive{
809+ Prefix: " ",
810+ Suffix: " ",
811+ Color: plainFg,
812+ BackgroundColor: plainBg,
813+ },
814+ },
815+ CodeBlock: ansi.StyleCodeBlock{
816+ StyleBlock: ansi.StyleBlock{
817+ StylePrimitive: ansi.StylePrimitive{
818+ Color: plainFg,
819+ BackgroundColor: plainBg,
820+ },
821+ Margin: uintPtr(defaultMargin),
822+ },
823+ },
824+ Table: ansi.StyleTable{
825+ StyleBlock: ansi.StyleBlock{
826+ StylePrimitive: ansi.StylePrimitive{
827+ Color: plainFg,
828+ BackgroundColor: plainBg,
829+ },
830+ },
831+ },
832+ DefinitionDescription: ansi.StylePrimitive{
833+ BlockPrefix: "\n ",
834+ Color: plainFg,
835+ BackgroundColor: plainBg,
836+ },
837+ }
838+
670 s.Help = help.Styles{
839 s.Help = help.Styles{
671 ShortKey: base.Foreground(fgMuted),
840 ShortKey: base.Foreground(fgMuted),
672 ShortDesc: base.Foreground(fgSubtle),
841 ShortDesc: base.Foreground(fgSubtle),
@@ -892,9 +1061,14 @@ func DefaultStyles() Styles {
892 BorderLeft(true).
1061 BorderLeft(true).
893 BorderForeground(greenDark)
1062 BorderForeground(greenDark)
894 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
1063 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
895− s.Chat.Message.ThinkingFooter = s.Base
896 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
1064 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
897
1065
1066+ // Thinking section styles
1067+ s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
1068+ s.Chat.Message.ThinkingTruncationHint = s.Muted
1069+ s.Chat.Message.ThinkingFooterTitle = s.Muted
1070+ s.Chat.Message.ThinkingFooterDuration = s.Subtle
1071+
898 // Text selection.
1072 // Text selection.
899 s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
1073 s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
900
1074
internal/ui/toolrender/render.godeleted−889
@@ -1,889 +0,0 @@
1−package toolrender
2−
3−import (
4− "cmp"
5− "encoding/json"
6− "fmt"
7− "strings"
8−
9− "charm.land/lipgloss/v2"
10− "charm.land/lipgloss/v2/tree"
11− "github.com/charmbracelet/crush/internal/agent"
12− "github.com/charmbracelet/crush/internal/agent/tools"
13− "github.com/charmbracelet/crush/internal/ansiext"
14− "github.com/charmbracelet/crush/internal/fsext"
15− "github.com/charmbracelet/crush/internal/message"
16− "github.com/charmbracelet/crush/internal/ui/common"
17− "github.com/charmbracelet/crush/internal/ui/styles"
18− "github.com/charmbracelet/x/ansi"
19−)
20−
21−// responseContextHeight limits the number of lines displayed in tool output.
22−const responseContextHeight = 10
23−
24−// RenderContext provides the context needed for rendering a tool call.
25−type RenderContext struct {
26− Call message.ToolCall
27− Result message.ToolResult
28− Cancelled bool
29− IsNested bool
30− Width int
31− Styles *styles.Styles
32−}
33−
34−// TextWidth returns the available width for content accounting for borders.
35−func (rc *RenderContext) TextWidth() int {
36− if rc.IsNested {
37− return rc.Width - 6
38− }
39− return rc.Width - 5
40−}
41−
42−// Fit truncates content to fit within the specified width with ellipsis.
43−func (rc *RenderContext) Fit(content string, width int) string {
44− lineStyle := rc.Styles.Muted
45− dots := lineStyle.Render("…")
46− return ansi.Truncate(content, width, dots)
47−}
48−
49−// Render renders a tool call using the appropriate renderer based on tool name.
50−func Render(ctx *RenderContext) string {
51− switch ctx.Call.Name {
52− case tools.ViewToolName:
53− return renderView(ctx)
54− case tools.EditToolName:
55− return renderEdit(ctx)
56− case tools.MultiEditToolName:
57− return renderMultiEdit(ctx)
58− case tools.WriteToolName:
59− return renderWrite(ctx)
60− case tools.BashToolName:
61− return renderBash(ctx)
62− case tools.JobOutputToolName:
63− return renderJobOutput(ctx)
64− case tools.JobKillToolName:
65− return renderJobKill(ctx)
66− case tools.FetchToolName:
67− return renderSimpleFetch(ctx)
68− case tools.AgenticFetchToolName:
69− return renderAgenticFetch(ctx)
70− case tools.WebFetchToolName:
71− return renderWebFetch(ctx)
72− case tools.DownloadToolName:
73− return renderDownload(ctx)
74− case tools.GlobToolName:
75− return renderGlob(ctx)
76− case tools.GrepToolName:
77− return renderGrep(ctx)
78− case tools.LSToolName:
79− return renderLS(ctx)
80− case tools.SourcegraphToolName:
81− return renderSourcegraph(ctx)
82− case tools.DiagnosticsToolName:
83− return renderDiagnostics(ctx)
84− case agent.AgentToolName:
85− return renderAgent(ctx)
86− default:
87− return renderGeneric(ctx)
88− }
89−}
90−
91−// Helper functions
92−
93−func unmarshalParams(input string, target any) error {
94− return json.Unmarshal([]byte(input), target)
95−}
96−
97−type paramBuilder struct {
98− args []string
99−}
100−
101−func newParamBuilder() *paramBuilder {
102− return &paramBuilder{args: make([]string, 0)}
103−}
104−
105−func (pb *paramBuilder) addMain(value string) *paramBuilder {
106− if value != "" {
107− pb.args = append(pb.args, value)
108− }
109− return pb
110−}
111−
112−func (pb *paramBuilder) addKeyValue(key, value string) *paramBuilder {
113− if value != "" {
114− pb.args = append(pb.args, key, value)
115− }
116− return pb
117−}
118−
119−func (pb *paramBuilder) addFlag(key string, value bool) *paramBuilder {
120− if value {
121− pb.args = append(pb.args, key, "true")
122− }
123− return pb
124−}
125−
126−func (pb *paramBuilder) build() []string {
127− return pb.args
128−}
129−
130−func formatNonZero[T comparable](value T) string {
131− var zero T
132− if value == zero {
133− return ""
134− }
135− return fmt.Sprintf("%v", value)
136−}
137−
138−func makeHeader(ctx *RenderContext, toolName string, args []string) string {
139− if ctx.IsNested {
140− return makeNestedHeader(ctx, toolName, args)
141− }
142− s := ctx.Styles
143− var icon string
144− if ctx.Result.ToolCallID != "" {
145− if ctx.Result.IsError {
146− icon = s.Tool.IconError.Render()
147− } else {
148− icon = s.Tool.IconSuccess.Render()
149− }
150− } else if ctx.Cancelled {
151− icon = s.Tool.IconCancelled.Render()
152− } else {
153− icon = s.Tool.IconPending.Render()
154− }
155− tool := s.Tool.NameNormal.Render(toolName)
156− prefix := fmt.Sprintf("%s %s ", icon, tool)
157− return prefix + renderParamList(ctx, false, ctx.TextWidth()-lipgloss.Width(prefix), args...)
158−}
159−
160−func makeNestedHeader(ctx *RenderContext, toolName string, args []string) string {
161− s := ctx.Styles
162− var icon string
163− if ctx.Result.ToolCallID != "" {
164− if ctx.Result.IsError {
165− icon = s.Tool.IconError.Render()
166− } else {
167− icon = s.Tool.IconSuccess.Render()
168− }
169− } else if ctx.Cancelled {
170− icon = s.Tool.IconCancelled.Render()
171− } else {
172− icon = s.Tool.IconPending.Render()
173− }
174− tool := s.Tool.NameNested.Render(toolName)
175− prefix := fmt.Sprintf("%s %s ", icon, tool)
176− return prefix + renderParamList(ctx, true, ctx.TextWidth()-lipgloss.Width(prefix), args...)
177−}
178−
179−func renderParamList(ctx *RenderContext, nested bool, paramsWidth int, params ...string) string {
180− s := ctx.Styles
181− if len(params) == 0 {
182− return ""
183− }
184− mainParam := params[0]
185− if paramsWidth >= 0 && lipgloss.Width(mainParam) > paramsWidth {
186− mainParam = ansi.Truncate(mainParam, paramsWidth, "…")
187− }
188−
189− if len(params) == 1 {
190− return s.Tool.ParamMain.Render(mainParam)
191− }
192− otherParams := params[1:]
193− if len(otherParams)%2 != 0 {
194− otherParams = append(otherParams, "")
195− }
196− parts := make([]string, 0, len(otherParams)/2)
197− for i := 0; i < len(otherParams); i += 2 {
198− key := otherParams[i]
199− value := otherParams[i+1]
200− if value == "" {
201− continue
202− }
203− parts = append(parts, fmt.Sprintf("%s=%s", key, value))
204− }
205−
206− partsRendered := strings.Join(parts, ", ")
207− remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3
208− if remainingWidth < 30 {
209− return s.Tool.ParamMain.Render(mainParam)
210− }
211−
212− if len(parts) > 0 {
213− mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
214− }
215−
216− return s.Tool.ParamMain.Render(ansi.Truncate(mainParam, paramsWidth, "…"))
217−}
218−
219−func earlyState(ctx *RenderContext, header string) (string, bool) {
220− s := ctx.Styles
221− message := ""
222− switch {
223− case ctx.Result.IsError:
224− message = renderToolError(ctx)
225− case ctx.Cancelled:
226− message = s.Tool.StateCancelled.Render("Canceled.")
227− case ctx.Result.ToolCallID == "":
228− message = s.Tool.StateWaiting.Render("Waiting for tool response...")
229− default:
230− return "", false
231− }
232−
233− message = s.Tool.BodyPadding.Render(message)
234− return lipgloss.JoinVertical(lipgloss.Left, header, "", message), true
235−}
236−
237−func renderToolError(ctx *RenderContext) string {
238− s := ctx.Styles
239− errTag := s.Tool.ErrorTag.Render("ERROR")
240− msg := ctx.Result.Content
241− if msg == "" {
242− msg = "An error occurred"
243− }
244− truncated := ansi.Truncate(msg, ctx.TextWidth()-3-lipgloss.Width(errTag), "…")
245− return errTag + " " + s.Tool.ErrorMessage.Render(truncated)
246−}
247−
248−func joinHeaderBody(ctx *RenderContext, header, body string) string {
249− s := ctx.Styles
250− if body == "" {
251− return header
252− }
253− body = s.Tool.BodyPadding.Render(body)
254− return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
255−}
256−
257−func renderWithParams(ctx *RenderContext, toolName string, args []string, contentRenderer func() string) string {
258− header := makeHeader(ctx, toolName, args)
259− if ctx.IsNested {
260− return header
261− }
262− if res, done := earlyState(ctx, header); done {
263− return res
264− }
265− body := contentRenderer()
266− return joinHeaderBody(ctx, header, body)
267−}
268−
269−func renderError(ctx *RenderContext, message string) string {
270− s := ctx.Styles
271− header := makeHeader(ctx, prettifyToolName(ctx.Call.Name), []string{})
272− errorTag := s.Tool.ErrorTag.Render("ERROR")
273− message = s.Tool.ErrorMessage.Render(ctx.Fit(message, ctx.TextWidth()-3-lipgloss.Width(errorTag)))
274− return joinHeaderBody(ctx, header, errorTag+" "+message)
275−}
276−
277−func renderPlainContent(ctx *RenderContext, content string) string {
278− s := ctx.Styles
279− content = strings.ReplaceAll(content, "\r\n", "\n")
280− content = strings.ReplaceAll(content, "\t", " ")
281− content = strings.TrimSpace(content)
282− lines := strings.Split(content, "\n")
283−
284− width := ctx.TextWidth() - 2
285− var out []string
286− for i, ln := range lines {
287− if i >= responseContextHeight {
288− break
289− }
290− ln = ansiext.Escape(ln)
291− ln = " " + ln
292− if len(ln) > width {
293− ln = ctx.Fit(ln, width)
294− }
295− out = append(out, s.Tool.ContentLine.Width(width).Render(ln))
296− }
297−
298− if len(lines) > responseContextHeight {
299− out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
300− }
301−
302− return strings.Join(out, "\n")
303−}
304−
305−func renderMarkdownContent(ctx *RenderContext, content string) string {
306− s := ctx.Styles
307− content = strings.ReplaceAll(content, "\r\n", "\n")
308− content = strings.ReplaceAll(content, "\t", " ")
309− content = strings.TrimSpace(content)
310−
311− width := ctx.TextWidth() - 2
312− width = min(width, 120)
313−
314− renderer := common.PlainMarkdownRenderer(width)
315− rendered, err := renderer.Render(content)
316− if err != nil {
317− return renderPlainContent(ctx, content)
318− }
319−
320− lines := strings.Split(rendered, "\n")
321−
322− var out []string
323− for i, ln := range lines {
324− if i >= responseContextHeight {
325− break
326− }
327− out = append(out, ln)
328− }
329−
330− style := s.Tool.ContentLine
331− if len(lines) > responseContextHeight {
332− out = append(out, s.Tool.ContentTruncation.
333− Width(width-2).
334− Render(fmt.Sprintf("… (%d lines)", len(lines)-responseContextHeight)))
335− }
336−
337− return style.Render(strings.Join(out, "\n"))
338−}
339−
340−func renderCodeContent(ctx *RenderContext, path, content string, offset int) string {
341− s := ctx.Styles
342− content = strings.ReplaceAll(content, "\r\n", "\n")
343− content = strings.ReplaceAll(content, "\t", " ")
344− truncated := truncateHeight(content, responseContextHeight)
345−
346− lines := strings.Split(truncated, "\n")
347− for i, ln := range lines {
348− lines[i] = ansiext.Escape(ln)
349− }
350−
351− bg := s.Tool.ContentCodeBg
352− highlighted, _ := common.SyntaxHighlight(ctx.Styles, strings.Join(lines, "\n"), path, bg)
353− lines = strings.Split(highlighted, "\n")
354−
355− width := ctx.TextWidth() - 2
356− gutterWidth := getDigits(offset+len(lines)) + 1
357−
358− var out []string
359− for i, ln := range lines {
360− lineNum := fmt.Sprintf("%*d", gutterWidth, offset+i+1)
361− gutter := s.Subtle.Render(lineNum + " ")
362− ln = " " + ln
363− if lipgloss.Width(gutter+ln) > width {
364− ln = ctx.Fit(ln, width-lipgloss.Width(gutter))
365− }
366− out = append(out, s.Tool.ContentCodeLine.Width(width).Render(gutter+ln))
367− }
368−
369− contentLines := strings.Split(content, "\n")
370− if len(contentLines) > responseContextHeight {
371− out = append(out, s.Tool.ContentTruncation.Width(width).Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight)))
372− }
373−
374− return strings.Join(out, "\n")
375−}
376−
377−func getDigits(n int) int {
378− if n == 0 {
379− return 1
380− }
381− if n < 0 {
382− n = -n
383− }
384−
385− digits := 0
386− for n > 0 {
387− n /= 10
388− digits++
389− }
390−
391− return digits
392−}
393−
394−func truncateHeight(content string, maxLines int) string {
395− lines := strings.Split(content, "\n")
396− if len(lines) <= maxLines {
397− return content
398− }
399− return strings.Join(lines[:maxLines], "\n")
400−}
401−
402−func prettifyToolName(name string) string {
403− switch name {
404− case "agent":
405− return "Agent"
406− case "bash":
407− return "Bash"
408− case "job_output":
409− return "Job: Output"
410− case "job_kill":
411− return "Job: Kill"
412− case "download":
413− return "Download"
414− case "edit":
415− return "Edit"
416− case "multiedit":
417− return "Multi-Edit"
418− case "fetch":
419− return "Fetch"
420− case "agentic_fetch":
421− return "Agentic Fetch"
422− case "web_fetch":
423− return "Fetching"
424− case "glob":
425− return "Glob"
426− case "grep":
427− return "Grep"
428− case "ls":
429− return "List"
430− case "sourcegraph":
431− return "Sourcegraph"
432− case "view":
433− return "View"
434− case "write":
435− return "Write"
436− case "lsp_references":
437− return "Find References"
438− case "lsp_diagnostics":
439− return "Diagnostics"
440− default:
441− parts := strings.Split(name, "_")
442− for i := range parts {
443− if len(parts[i]) > 0 {
444− parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
445− }
446− }
447− return strings.Join(parts, " ")
448− }
449−}
450−
451−// Tool-specific renderers
452−
453−func renderGeneric(ctx *RenderContext) string {
454− return renderWithParams(ctx, prettifyToolName(ctx.Call.Name), []string{ctx.Call.Input}, func() string {
455− return renderPlainContent(ctx, ctx.Result.Content)
456− })
457−}
458−
459−func renderView(ctx *RenderContext) string {
460− var params tools.ViewParams
461− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
462− return renderError(ctx, "Invalid view parameters")
463− }
464−
465− file := fsext.PrettyPath(params.FilePath)
466− args := newParamBuilder().
467− addMain(file).
468− addKeyValue("limit", formatNonZero(params.Limit)).
469− addKeyValue("offset", formatNonZero(params.Offset)).
470− build()
471−
472− return renderWithParams(ctx, "View", args, func() string {
473− var meta tools.ViewResponseMetadata
474− if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
475− return renderPlainContent(ctx, ctx.Result.Content)
476− }
477− return renderCodeContent(ctx, meta.FilePath, meta.Content, params.Offset)
478− })
479−}
480−
481−func renderEdit(ctx *RenderContext) string {
482− s := ctx.Styles
483− var params tools.EditParams
484− var args []string
485− if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
486− file := fsext.PrettyPath(params.FilePath)
487− args = newParamBuilder().addMain(file).build()
488− }
489−
490− return renderWithParams(ctx, "Edit", args, func() string {
491− var meta tools.EditResponseMetadata
492− if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
493− return renderPlainContent(ctx, ctx.Result.Content)
494− }
495−
496− formatter := common.DiffFormatter(ctx.Styles).
497− Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
498− After(fsext.PrettyPath(params.FilePath), meta.NewContent).
499− Width(ctx.TextWidth() - 2)
500− if ctx.TextWidth() > 120 {
501− formatter = formatter.Split()
502− }
503− formatted := formatter.String()
504− if lipgloss.Height(formatted) > responseContextHeight {
505− contentLines := strings.Split(formatted, "\n")
506− truncateMessage := s.Tool.DiffTruncation.
507− Width(ctx.TextWidth() - 2).
508− Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
509− formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
510− }
511− return formatted
512− })
513−}
514−
515−func renderMultiEdit(ctx *RenderContext) string {
516− s := ctx.Styles
517− var params tools.MultiEditParams
518− var args []string
519− if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
520− file := fsext.PrettyPath(params.FilePath)
521− args = newParamBuilder().
522− addMain(file).
523− addKeyValue("edits", fmt.Sprintf("%d", len(params.Edits))).
524− build()
525− }
526−
527− return renderWithParams(ctx, "Multi-Edit", args, func() string {
528− var meta tools.MultiEditResponseMetadata
529− if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
530− return renderPlainContent(ctx, ctx.Result.Content)
531− }
532−
533− formatter := common.DiffFormatter(ctx.Styles).
534− Before(fsext.PrettyPath(params.FilePath), meta.OldContent).
535− After(fsext.PrettyPath(params.FilePath), meta.NewContent).
536− Width(ctx.TextWidth() - 2)
537− if ctx.TextWidth() > 120 {
538− formatter = formatter.Split()
539− }
540− formatted := formatter.String()
541− if lipgloss.Height(formatted) > responseContextHeight {
542− contentLines := strings.Split(formatted, "\n")
543− truncateMessage := s.Tool.DiffTruncation.
544− Width(ctx.TextWidth() - 2).
545− Render(fmt.Sprintf("… (%d lines)", len(contentLines)-responseContextHeight))
546− formatted = strings.Join(contentLines[:responseContextHeight], "\n") + "\n" + truncateMessage
547− }
548−
549− // Add note about failed edits if any.
550− if len(meta.EditsFailed) > 0 {
551− noteTag := s.Tool.NoteTag.Render("NOTE")
552− noteMsg := s.Tool.NoteMessage.Render(
553− fmt.Sprintf("%d of %d edits failed", len(meta.EditsFailed), len(params.Edits)))
554− formatted = formatted + "\n\n" + noteTag + " " + noteMsg
555− }
556−
557− return formatted
558− })
559−}
560−
561−func renderWrite(ctx *RenderContext) string {
562− var params tools.WriteParams
563− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
564− return renderError(ctx, "Invalid write parameters")
565− }
566−
567− file := fsext.PrettyPath(params.FilePath)
568− args := newParamBuilder().addMain(file).build()
569−
570− return renderWithParams(ctx, "Write", args, func() string {
571− return renderCodeContent(ctx, params.FilePath, params.Content, 0)
572− })
573−}
574−
575−func renderBash(ctx *RenderContext) string {
576− var params tools.BashParams
577− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
578− return renderError(ctx, "Invalid bash parameters")
579− }
580−
581− cmd := strings.ReplaceAll(params.Command, "\n", " ")
582− cmd = strings.ReplaceAll(cmd, "\t", " ")
583− args := newParamBuilder().
584− addMain(cmd).
585− addFlag("background", params.RunInBackground).
586− build()
587−
588− if ctx.Call.Finished {
589− var meta tools.BashResponseMetadata
590− _ = unmarshalParams(ctx.Result.Metadata, &meta)
591− if meta.Background {
592− description := cmp.Or(meta.Description, params.Command)
593− width := ctx.TextWidth()
594− if ctx.IsNested {
595− width -= 4
596− }
597− header := makeJobHeader(ctx, "Start", fmt.Sprintf("PID %s", meta.ShellID), description, width)
598− if ctx.IsNested {
599− return header
600− }
601− if res, done := earlyState(ctx, header); done {
602− return res
603− }
604− content := "Command: " + params.Command + "\n" + ctx.Result.Content
605− body := renderPlainContent(ctx, content)
606− return joinHeaderBody(ctx, header, body)
607− }
608− }
609−
610− return renderWithParams(ctx, "Bash", args, func() string {
611− var meta tools.BashResponseMetadata
612− if err := unmarshalParams(ctx.Result.Metadata, &meta); err != nil {
613− return renderPlainContent(ctx, ctx.Result.Content)
614− }
615− if meta.Output == "" && ctx.Result.Content != tools.BashNoOutput {
616− meta.Output = ctx.Result.Content
617− }
618−
619− if meta.Output == "" {
620− return ""
621− }
622− return renderPlainContent(ctx, meta.Output)
623− })
624−}
625−
626−func makeJobHeader(ctx *RenderContext, action, pid, description string, width int) string {
627− s := ctx.Styles
628− icon := s.Tool.JobIconPending.Render(styles.ToolPending)
629− if ctx.Result.ToolCallID != "" {
630− if ctx.Result.IsError {
631− icon = s.Tool.JobIconError.Render(styles.ToolError)
632− } else {
633− icon = s.Tool.JobIconSuccess.Render(styles.ToolSuccess)
634− }
635− } else if ctx.Cancelled {
636− icon = s.Muted.Render(styles.ToolPending)
637− }
638−
639− toolName := s.Tool.JobToolName.Render("Bash")
640− actionPart := s.Tool.JobAction.Render(action)
641− pidPart := s.Tool.JobPID.Render(pid)
642−
643− prefix := fmt.Sprintf("%s %s %s %s ", icon, toolName, actionPart, pidPart)
644− remainingWidth := width - lipgloss.Width(prefix)
645−
646− descDisplay := ansi.Truncate(description, remainingWidth, "…")
647− descDisplay = s.Tool.JobDescription.Render(descDisplay)
648−
649− return prefix + descDisplay
650−}
651−
652−func renderJobOutput(ctx *RenderContext) string {
653− var params tools.JobOutputParams
654− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
655− return renderError(ctx, "Invalid job output parameters")
656− }
657−
658− width := ctx.TextWidth()
659− if ctx.IsNested {
660− width -= 4
661− }
662−
663− var meta tools.JobOutputResponseMetadata
664− _ = unmarshalParams(ctx.Result.Metadata, &meta)
665− description := cmp.Or(meta.Description, meta.Command)
666−
667− header := makeJobHeader(ctx, "Output", fmt.Sprintf("PID %s", params.ShellID), description, width)
668− if ctx.IsNested {
669− return header
670− }
671− if res, done := earlyState(ctx, header); done {
672− return res
673− }
674− body := renderPlainContent(ctx, ctx.Result.Content)
675− return joinHeaderBody(ctx, header, body)
676−}
677−
678−func renderJobKill(ctx *RenderContext) string {
679− var params tools.JobKillParams
680− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
681− return renderError(ctx, "Invalid job kill parameters")
682− }
683−
684− width := ctx.TextWidth()
685− if ctx.IsNested {
686− width -= 4
687− }
688−
689− var meta tools.JobKillResponseMetadata
690− _ = unmarshalParams(ctx.Result.Metadata, &meta)
691− description := cmp.Or(meta.Description, meta.Command)
692−
693− header := makeJobHeader(ctx, "Kill", fmt.Sprintf("PID %s", params.ShellID), description, width)
694− if ctx.IsNested {
695− return header
696− }
697− if res, done := earlyState(ctx, header); done {
698− return res
699− }
700− body := renderPlainContent(ctx, ctx.Result.Content)
701− return joinHeaderBody(ctx, header, body)
702−}
703−
704−func renderSimpleFetch(ctx *RenderContext) string {
705− var params tools.FetchParams
706− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
707− return renderError(ctx, "Invalid fetch parameters")
708− }
709−
710− args := newParamBuilder().
711− addMain(params.URL).
712− addKeyValue("format", params.Format).
713− addKeyValue("timeout", formatNonZero(params.Timeout)).
714− build()
715−
716− return renderWithParams(ctx, "Fetch", args, func() string {
717− path := "file." + params.Format
718− return renderCodeContent(ctx, path, ctx.Result.Content, 0)
719− })
720−}
721−
722−func renderAgenticFetch(ctx *RenderContext) string {
723− // TODO: Implement nested tool call rendering with tree.
724− return renderGeneric(ctx)
725−}
726−
727−func renderWebFetch(ctx *RenderContext) string {
728− var params tools.WebFetchParams
729− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
730− return renderError(ctx, "Invalid web fetch parameters")
731− }
732−
733− args := newParamBuilder().addMain(params.URL).build()
734−
735− return renderWithParams(ctx, "Fetching", args, func() string {
736− return renderMarkdownContent(ctx, ctx.Result.Content)
737− })
738−}
739−
740−func renderDownload(ctx *RenderContext) string {
741− var params tools.DownloadParams
742− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
743− return renderError(ctx, "Invalid download parameters")
744− }
745−
746− args := newParamBuilder().
747− addMain(params.URL).
748− addKeyValue("file", fsext.PrettyPath(params.FilePath)).
749− addKeyValue("timeout", formatNonZero(params.Timeout)).
750− build()
751−
752− return renderWithParams(ctx, "Download", args, func() string {
753− return renderPlainContent(ctx, ctx.Result.Content)
754− })
755−}
756−
757−func renderGlob(ctx *RenderContext) string {
758− var params tools.GlobParams
759− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
760− return renderError(ctx, "Invalid glob parameters")
761− }
762−
763− args := newParamBuilder().
764− addMain(params.Pattern).
765− addKeyValue("path", params.Path).
766− build()
767−
768− return renderWithParams(ctx, "Glob", args, func() string {
769− return renderPlainContent(ctx, ctx.Result.Content)
770− })
771−}
772−
773−func renderGrep(ctx *RenderContext) string {
774− var params tools.GrepParams
775− var args []string
776− if err := unmarshalParams(ctx.Call.Input, &params); err == nil {
777− args = newParamBuilder().
778− addMain(params.Pattern).
779− addKeyValue("path", params.Path).
780− addKeyValue("include", params.Include).
781− addFlag("literal", params.LiteralText).
782− build()
783− }
784−
785− return renderWithParams(ctx, "Grep", args, func() string {
786− return renderPlainContent(ctx, ctx.Result.Content)
787− })
788−}
789−
790−func renderLS(ctx *RenderContext) string {
791− var params tools.LSParams
792− path := cmp.Or(params.Path, ".")
793− args := newParamBuilder().addMain(path).build()
794−
795− if err := unmarshalParams(ctx.Call.Input, &params); err == nil && params.Path != "" {
796− args = newParamBuilder().addMain(params.Path).build()
797− }
798−
799− return renderWithParams(ctx, "List", args, func() string {
800− return renderPlainContent(ctx, ctx.Result.Content)
801− })
802−}
803−
804−func renderSourcegraph(ctx *RenderContext) string {
805− var params tools.SourcegraphParams
806− if err := unmarshalParams(ctx.Call.Input, &params); err != nil {
807− return renderError(ctx, "Invalid sourcegraph parameters")
808− }
809−
810− args := newParamBuilder().
811− addMain(params.Query).
812− addKeyValue("count", formatNonZero(params.Count)).
813− addKeyValue("context", formatNonZero(params.ContextWindow)).
814− build()
815−
816− return renderWithParams(ctx, "Sourcegraph", args, func() string {
817− return renderPlainContent(ctx, ctx.Result.Content)
818− })
819−}
820−
821−func renderDiagnostics(ctx *RenderContext) string {
822− args := newParamBuilder().addMain("project").build()
823−
824− return renderWithParams(ctx, "Diagnostics", args, func() string {
825− return renderPlainContent(ctx, ctx.Result.Content)
826− })
827−}
828−
829−func renderAgent(ctx *RenderContext) string {
830− s := ctx.Styles
831− var params agent.AgentParams
832− unmarshalParams(ctx.Call.Input, &params)
833−
834− prompt := params.Prompt
835− prompt = strings.ReplaceAll(prompt, "\n", " ")
836−
837− header := makeHeader(ctx, "Agent", []string{})
838− if res, done := earlyState(ctx, header); ctx.Cancelled && done {
839− return res
840− }
841− taskTag := s.Tool.AgentTaskTag.Render("Task")
842− remainingWidth := ctx.TextWidth() - lipgloss.Width(header) - lipgloss.Width(taskTag) - 2
843− remainingWidth = min(remainingWidth, 120-lipgloss.Width(taskTag)-2)
844− prompt = s.Tool.AgentPrompt.Width(remainingWidth).Render(prompt)
845− header = lipgloss.JoinVertical(
846− lipgloss.Left,
847− header,
848− "",
849− lipgloss.JoinHorizontal(
850− lipgloss.Left,
851− taskTag,
852− " ",
853− prompt,
854− ),
855− )
856− childTools := tree.Root(header)
857−
858− // TODO: Render nested tool calls when available.
859−
860− parts := []string{
861− childTools.Enumerator(roundedEnumeratorWithWidth(2, lipgloss.Width(taskTag)-5)).String(),
862− }
863−
864− if ctx.Result.ToolCallID == "" {
865− // Pending state - would show animation in TUI.
866− parts = append(parts, "", s.Subtle.Render("Working..."))
867− }
868−
869− header = lipgloss.JoinVertical(
870− lipgloss.Left,
871− parts...,
872− )
873−
874− if ctx.Result.ToolCallID == "" {
875− return header
876− }
877−
878− body := renderMarkdownContent(ctx, ctx.Result.Content)
879− return joinHeaderBody(ctx, header, body)
880−}
881−
882−func roundedEnumeratorWithWidth(width int, offset int) func(tree.Children, int) string {
883− return func(children tree.Children, i int) string {
884− if children.Length()-1 == i {
885− return strings.Repeat(" ", offset) + "└" + strings.Repeat("─", width-1) + " "
886− }
887− return strings.Repeat(" ", offset) + "├" + strings.Repeat("─", width-1) + " "
888− }
889−}