on main help sign in
refactor(chat): simple assistant message
commit3d9d4e6a3d2c800d52269b0d234e10df56029a2f authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 17 Dec 2025, 16:35 (1 year ago) parent4f07a54
10 files changed+1006−935
cspell.json+1−1 internal/ui/anim/anim.go+445 internal/ui/chat/assistant.go+227 internal/ui/chat/messages.go+44−1 internal/ui/chat/user.go+3−7 internal/ui/common/markdown.go+4−5 internal/ui/model/chat.go+33−13 internal/ui/model/ui.go+70−16 internal/ui/styles/styles.go+179−3 internal/ui/toolrender/render.go−889
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+227
@@ -0,0 +1,227 @@
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+func (a *AssistantMessageItem) StartAnimation() tea.Cmd {
56+ if !a.isSpinning() {
57+ return nil
58+ }
59+ return a.anim.Start()
60+}
61+
62+func (a *AssistantMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
63+ if !a.isSpinning() {
64+ return nil
65+ }
66+ return a.anim.Animate(msg)
67+}
68+
69+// ID implements MessageItem.
70+func (a *AssistantMessageItem) ID() string {
71+ return a.message.ID
72+}
73+
74+// Render implements MessageItem.
75+func (a *AssistantMessageItem) Render(width int) string {
76+ cappedWidth := cappedMessageWidth(width)
77+ style := a.sty.Chat.Message.AssistantBlurred
78+ if a.focused {
79+ style = a.sty.Chat.Message.AssistantFocused
80+ }
81+
82+ var spinner string
83+ if a.isSpinning() {
84+ spinner = a.renderSpinning()
85+ }
86+
87+ content, height, ok := a.getCachedRender(cappedWidth)
88+ if !ok {
89+ content = a.renderMessageContent(cappedWidth)
90+ height = lipgloss.Height(content)
91+ // cache the rendered content
92+ a.setCachedRender(content, cappedWidth, height)
93+ }
94+
95+ highlightedContent := a.renderHighlighted(content, cappedWidth, height)
96+ if spinner != "" {
97+ if highlightedContent != "" {
98+ highlightedContent += "\n\n"
99+ }
100+ return style.Render(highlightedContent + spinner)
101+ }
102+
103+ return style.Render(highlightedContent)
104+}
105+
106+func (a *AssistantMessageItem) renderMessageContent(width int) string {
107+ var messageParts []string
108+ thinking := strings.TrimSpace(a.message.ReasoningContent().Thinking)
109+ content := strings.TrimSpace(a.message.Content().Text)
110+ // if the massage has reasoning content add that first
111+ if thinking != "" {
112+ messageParts = append(messageParts, a.renderThinking(a.message.ReasoningContent().Thinking, width))
113+ }
114+
115+ // then add the main content
116+ if content != "" {
117+ // add a spacer between thinking and content
118+ if thinking != "" {
119+ messageParts = append(messageParts, "")
120+ }
121+ messageParts = append(messageParts, a.renderMarkdown(content, width))
122+ }
123+
124+ // finally add any finish reason info
125+ if a.message.IsFinished() {
126+ switch a.message.FinishReason() {
127+ case message.FinishReasonCanceled:
128+ messageParts = append(messageParts, a.sty.Base.Italic(true).Render("Canceled"))
129+ case message.FinishReasonError:
130+ messageParts = append(messageParts, a.renderError(width))
131+ }
132+ }
133+
134+ return strings.Join(messageParts, "\n")
135+}
136+
137+// renderThinking renders the thinking/reasoning content with footer.
138+func (a *AssistantMessageItem) renderThinking(thinking string, width int) string {
139+ renderer := common.PlainMarkdownRenderer(a.sty, width)
140+ rendered, err := renderer.Render(thinking)
141+ if err != nil {
142+ rendered = thinking
143+ }
144+ rendered = strings.TrimSpace(rendered)
145+
146+ lines := strings.Split(rendered, "\n")
147+ totalLines := len(lines)
148+
149+ isTruncated := totalLines > maxCollapsedThinkingHeight
150+ if !a.thinkingExpanded && isTruncated {
151+ lines = lines[totalLines-maxCollapsedThinkingHeight:]
152+ }
153+
154+ if !a.thinkingExpanded && isTruncated {
155+ hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
156+ fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
157+ )
158+ lines = append([]string{hint}, lines...)
159+ }
160+
161+ thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
162+ result := thinkingStyle.Render(strings.Join(lines, "\n"))
163+ a.thinkingBoxHeight = lipgloss.Height(result)
164+
165+ var footer string
166+ // if thinking is done add the thought for footer
167+ if !a.message.IsThinking() {
168+ duration := a.message.ThinkingDuration()
169+ if duration.String() != "0s" {
170+ footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
171+ a.sty.Chat.Message.ThinkingFooterDuration.Render(duration.String())
172+ }
173+ }
174+
175+ if footer != "" {
176+ result += "\n\n" + footer
177+ }
178+
179+ return result
180+}
181+
182+// renderMarkdown renders content as markdown.
183+func (a *AssistantMessageItem) renderMarkdown(content string, width int) string {
184+ renderer := common.MarkdownRenderer(a.sty, width)
185+ result, err := renderer.Render(content)
186+ if err != nil {
187+ return content
188+ }
189+ return strings.TrimSuffix(result, "\n")
190+}
191+
192+func (a *AssistantMessageItem) renderSpinning() string {
193+ if a.message.IsThinking() {
194+ a.anim.SetLabel("Thinking")
195+ } else if a.message.IsSummaryMessage {
196+ a.anim.SetLabel("Summarizing")
197+ }
198+ return a.anim.Render()
199+}
200+
201+// renderError renders an error message.
202+func (a *AssistantMessageItem) renderError(width int) string {
203+ finishPart := a.message.FinishPart()
204+ errTag := a.sty.Chat.Message.ErrorTag.Render("ERROR")
205+ truncated := ansi.Truncate(finishPart.Message, width-2-lipgloss.Width(errTag), "...")
206+ title := fmt.Sprintf("%s %s", errTag, a.sty.Chat.Message.ErrorTitle.Render(truncated))
207+ details := a.sty.Chat.Message.ErrorDetails.Width(width - 2).Render(finishPart.Details)
208+ return fmt.Sprintf("%s\n\n%s", title, details)
209+}
210+
211+// isSpinning returns true if the assistant message is still generating.
212+func (a *AssistantMessageItem) isSpinning() bool {
213+ isThinking := a.message.IsThinking()
214+ isFinished := a.message.IsFinished()
215+ return isThinking || !isFinished
216+}
217+
218+// SetMessage is used to update the underlying message.
219+func (a *AssistantMessageItem) SetMessage(message *message.Message) tea.Cmd {
220+ wasSpinning := a.isSpinning()
221+ a.message = message
222+ a.clearCache()
223+ if !wasSpinning && a.isSpinning() {
224+ return a.StartAnimation()
225+ }
226+ return nil
227+}
internal/ui/chat/messages.go+44−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.
14package chat
25
36import (
47 "image"
8+ "strings"
59
10+ tea "charm.land/bubbletea/v2"
611 "github.com/charmbracelet/crush/internal/message"
12+ "github.com/charmbracelet/crush/internal/ui/anim"
713 "github.com/charmbracelet/crush/internal/ui/list"
814 "github.com/charmbracelet/crush/internal/ui/styles"
915)
@@ -20,6 +26,11 @@ type Identifiable interface {
2026 ID() string
2127}
2228
29+type Animatable interface {
30+ StartAnimation() tea.Cmd
31+ Animate(msg anim.StepMsg) tea.Cmd
32+}
33+
2334// MessageItem represents a [message.Message] item that can be displayed in the
2435// UI and be part of a [list.List] identifiable by a unique ID.
2536type MessageItem interface {
@@ -84,7 +95,7 @@ func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem {
8495
8596// cachedMessageItem caches rendered message content to avoid re-rendering.
8697//
87−// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on
98+// This should be used by any message that can store a cached version of its render. e.x user,assistant... and so on
8899//
89100// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths
90101// the issue with that could be memory usage
@@ -111,6 +122,23 @@ func (c *cachedMessageItem) setCachedRender(rendered string, width, height int)
111122 c.height = height
112123}
113124
125+// clearCache clears the cached render.
126+func (c *cachedMessageItem) clearCache() {
127+ c.rendered = ""
128+ c.width = 0
129+ c.height = 0
130+}
131+
132+// focusableMessageItem is a base struct for message items that can be focused.
133+type focusableMessageItem struct {
134+ focused bool
135+}
136+
137+// SetFocused implements MessageItem.
138+func (f *focusableMessageItem) SetFocused(focused bool) {
139+ f.focused = focused
140+}
141+
114142// cappedMessageWidth returns the maximum width for message content for readability.
115143func cappedMessageWidth(availableWidth int) int {
116144 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
@@ -125,10 +153,25 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
125153 switch msg.Role {
126154 case message.User:
127155 return []MessageItem{NewUserMessageItem(sty, msg)}
156+ case message.Assistant:
157+ var items []MessageItem
158+ if shouldRenderAssistantMessage(msg) {
159+ items = append(items, NewAssistantMessageItem(sty, msg))
160+ }
161+ return items
128162 }
129163 return []MessageItem{}
130164}
131165
166+func shouldRenderAssistantMessage(msg *message.Message) bool {
167+ content := strings.TrimSpace(msg.Content().Text)
168+ thinking := strings.TrimSpace(msg.ReasoningContent().Thinking)
169+ isError := msg.FinishReason() == message.FinishReasonError
170+ isCancelled := msg.FinishReason() == message.FinishReasonCanceled
171+ hasToolCalls := len(msg.ToolCalls()) > 0
172+ return !hasToolCalls || content != "" || thinking != "" || msg.IsThinking() || isError || isCancelled
173+}
174+
132175// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages.
133176// Tool result messages (role == message.Tool) contain the results that should be linked
134177// to tool calls in assistant messages.
internal/ui/chat/user.go+3−7
@@ -16,9 +16,10 @@ import (
1616type UserMessageItem struct {
1717 *highlightableMessageItem
1818 *cachedMessageItem
19+ *focusableMessageItem
20+
1921 message *message.Message
2022 sty *styles.Styles
21− focused bool
2223}
2324
2425// NewUserMessageItem creates a new UserMessageItem.
@@ -26,9 +27,9 @@ func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageIte
2627 return &UserMessageItem{
2728 highlightableMessageItem: defaultHighlighter(sty),
2829 cachedMessageItem: &cachedMessageItem{},
30+ focusableMessageItem: &focusableMessageItem{},
2931 message: message,
3032 sty: sty,
31− focused: false,
3233 }
3334}
3435
@@ -67,11 +68,6 @@ func (m *UserMessageItem) Render(width int) string {
6768 return style.Render(m.renderHighlighted(content, cappedWidth, height))
6869}
6970
70−// SetFocused implements MessageItem.
71−func (m *UserMessageItem) SetFocused(focused bool) {
72− m.focused = focused
73−}
74−
7571// ID implements MessageItem.
7672func (m *UserMessageItem) ID() string {
7773 return m.message.ID
internal/ui/common/markdown.go+4−5
@@ -2,15 +2,14 @@ package common
22
33import (
44 "charm.land/glamour/v2"
5− gstyles "charm.land/glamour/v2/styles"
65 "github.com/charmbracelet/crush/internal/ui/styles"
76)
87
98// MarkdownRenderer returns a glamour [glamour.TermRenderer] configured with
109// 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 {
1211 r, _ := glamour.NewTermRenderer(
13− glamour.WithStyles(t.Markdown),
12+ glamour.WithStyles(sty.Markdown),
1413 glamour.WithWordWrap(width),
1514 )
1615 return r
@@ -18,9 +17,9 @@ func MarkdownRenderer(t *styles.Styles, width int) *glamour.TermRenderer {
1817
1918// PlainMarkdownRenderer returns a glamour [glamour.TermRenderer] with no colors
2019// (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 {
2221 r, _ := glamour.NewTermRenderer(
23− glamour.WithStyles(gstyles.ASCIIStyleConfig),
22+ glamour.WithStyles(sty.PlainMarkdown),
2423 glamour.WithWordWrap(width),
2524 )
2625 return r
internal/ui/model/chat.go+33−13
@@ -1,6 +1,8 @@
11package model
22
33import (
4+ tea "charm.land/bubbletea/v2"
5+ "github.com/charmbracelet/crush/internal/ui/anim"
46 "github.com/charmbracelet/crush/internal/ui/chat"
57 "github.com/charmbracelet/crush/internal/ui/common"
68 "github.com/charmbracelet/crush/internal/ui/list"
@@ -11,8 +13,9 @@ import (
1113// Chat represents the chat UI model that handles chat interactions and
1214// messages.
1315type Chat struct {
14− com *common.Common
15− list *list.List
16+ com *common.Common
17+ list *list.List
18+ idInxMap map[string]int // Map of message IDs to their indices in the list
1619
1720 // Mouse state
1821 mouseDown bool
@@ -27,7 +30,7 @@ type Chat struct {
2730// NewChat creates a new instance of [Chat] that handles chat interactions and
2831// messages.
2932func NewChat(com *common.Common) *Chat {
30− c := &Chat{com: com}
33+ c := &Chat{com: com, idInxMap: make(map[string]int)}
3134 l := list.NewList()
3235 l.SetGap(1)
3336 l.RegisterRenderCallback(c.applyHighlightRange)
@@ -57,16 +60,11 @@ func (m *Chat) Len() int {
5760 return m.list.Len()
5861}
5962
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−
6663// SetMessages sets the chat messages to the provided list of message items.
6764func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
6865 items := make([]list.Item, len(msgs))
6966 for i, msg := range msgs {
67+ m.idInxMap[msg.ID()] = i
7068 items[i] = msg
7169 }
7270 m.list.SetItems(items...)
@@ -76,16 +74,25 @@ func (m *Chat) SetMessages(msgs ...chat.MessageItem) {
7674// AppendMessages appends a new message item to the chat list.
7775func (m *Chat) AppendMessages(msgs ...chat.MessageItem) {
7876 items := make([]list.Item, len(msgs))
77+ indexOffset := len(m.idInxMap)
7978 for i, msg := range msgs {
79+ m.idInxMap[msg.ID()] = indexOffset + i
8080 items[i] = msg
8181 }
8282 m.list.AppendItems(items...)
8383}
8484
85−// AppendItems appends new items to the chat list.
86−func (m *Chat) AppendItems(items ...list.Item) {
87− m.list.AppendItems(items...)
88− m.list.ScrollToIndex(m.list.Len() - 1)
85+// Animate animated items in the chat list.
86+func (m *Chat) Animate(msg anim.StepMsg) tea.Cmd {
87+ item, ok := m.idInxMap[msg.ID]
88+ // Item with the given ID exists
89+ if !ok {
90+ return nil
91+ }
92+ if animatable, ok := m.list.ItemAt(item).(chat.Animatable); ok {
93+ return animatable.Animate(msg)
94+ }
95+ return nil
8996}
9097
9198// Focus sets the focus state of the chat component.
@@ -158,6 +165,19 @@ func (m *Chat) SelectLastInView() {
158165 m.list.SelectLastInView()
159166}
160167
168+// GetMessageItem returns the message item at the given id.
169+func (m *Chat) GetMessageItem(id string) chat.MessageItem {
170+ idx, ok := m.idInxMap[id]
171+ if !ok {
172+ return nil
173+ }
174+ item, ok := m.list.ItemAt(idx).(chat.MessageItem)
175+ if !ok {
176+ return nil
177+ }
178+ return item
179+}
180+
161181// HandleMouseDown handles mouse down events for the chat component.
162182func (m *Chat) HandleMouseDown(x, y int) bool {
163183 if m.list.Len() == 0 {
internal/ui/model/ui.go+70−16
@@ -27,7 +27,7 @@ import (
2727 "github.com/charmbracelet/crush/internal/pubsub"
2828 "github.com/charmbracelet/crush/internal/session"
2929 "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"
3131 "github.com/charmbracelet/crush/internal/ui/chat"
3232 "github.com/charmbracelet/crush/internal/ui/common"
3333 "github.com/charmbracelet/crush/internal/ui/dialog"
@@ -203,9 +203,20 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
203203 cmds = append(cmds, uiutil.ReportError(err))
204204 break
205205 }
206− m.setSessionMessages(msgs)
206+ if cmd := m.setSessionMessages(msgs); cmd != nil {
207+ cmds = append(cmds, cmd)
208+ }
207209
208210 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+ }
209220 // TODO: Finish implementing me
210221 // cmds = append(cmds, m.setMessageEvents(msg.Payload))
211222 case pubsub.Event[history.File]:
@@ -304,6 +315,12 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
304315 }
305316 }
306317 }
318+ case anim.StepMsg:
319+ if m.state == uiChat {
320+ if cmd := m.chat.Animate(msg); cmd != nil {
321+ cmds = append(cmds, cmd)
322+ }
323+ }
307324 case tea.KeyPressMsg:
308325 if cmd := m.handleKeyPressMsg(msg); cmd != nil {
309326 cmds = append(cmds, cmd)
@@ -336,7 +353,8 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
336353}
337354
338355// setSessionMessages sets the messages for the current session in the chat
339−func (m *UI) setSessionMessages(msgs []message.Message) {
356+func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
357+ var cmds []tea.Cmd
340358 // Build tool result map to link tool calls with their results
341359 msgPtrs := make([]*message.Message, len(msgs))
342360 for i := range msgs {
@@ -350,9 +368,47 @@ func (m *UI) setSessionMessages(msgs []message.Message) {
350368 items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
351369 }
352370
371+ // If the user switches between sessions while the agent is working we want
372+ // to make sure the animations are shown.
373+ for _, item := range items {
374+ if animatable, ok := item.(chat.Animatable); ok {
375+ if cmd := animatable.StartAnimation(); cmd != nil {
376+ cmds = append(cmds, cmd)
377+ }
378+ }
379+ }
380+
353381 m.chat.SetMessages(items...)
354382 m.chat.ScrollToBottom()
355383 m.chat.SelectLast()
384+ return tea.Batch(cmds...)
385+}
386+
387+func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
388+ items := chat.GetMessageItems(m.com.Styles, &msg, nil)
389+ var cmds []tea.Cmd
390+ for _, item := range items {
391+ if animatable, ok := item.(chat.Animatable); ok {
392+ if cmd := animatable.StartAnimation(); cmd != nil {
393+ cmds = append(cmds, cmd)
394+ }
395+ }
396+ }
397+ m.chat.AppendMessages(items...)
398+ m.chat.ScrollToBottom()
399+ return tea.Batch(cmds...)
400+}
401+
402+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
403+ existingItem := m.chat.GetMessageItem(msg.ID)
404+ switch msg.Role {
405+ case message.Assistant:
406+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
407+ assistantItem.SetMessage(&msg)
408+ }
409+ }
410+
411+ return nil
356412}
357413
358414func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -1161,33 +1217,31 @@ func (m *UI) renderSidebarLogo(width int) {
11611217
11621218// sendMessage sends a message with the given content and attachments.
11631219func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.Cmd {
1164− if m.session == nil {
1165− return uiutil.ReportError(fmt.Errorf("no session selected"))
1220+ if m.com.App.AgentCoordinator == nil {
1221+ return uiutil.ReportError(fmt.Errorf("coder agent is not initialized"))
11661222 }
1167− session := *m.session
1223+
11681224 var cmds []tea.Cmd
1169− if m.session.ID == "" {
1225+ if m.session == nil || m.session.ID == "" {
11701226 newSession, err := m.com.App.Sessions.Create(context.Background(), "New Session")
11711227 if err != nil {
11721228 return uiutil.ReportError(err)
11731229 }
1174− session = newSession
1175− cmds = append(cmds, m.loadSession(session.ID))
1176− }
1177− if m.com.App.AgentCoordinator == nil {
1178− return util.ReportError(fmt.Errorf("coder agent is not initialized"))
1230+ m.state = uiChat
1231+ m.session = &newSession
1232+ cmds = append(cmds, m.loadSession(newSession.ID))
11791233 }
1180− m.chat.ScrollToBottom()
1234+
11811235 cmds = append(cmds, func() tea.Msg {
1182− _, err := m.com.App.AgentCoordinator.Run(context.Background(), session.ID, content, attachments...)
1236+ _, err := m.com.App.AgentCoordinator.Run(context.Background(), m.session.ID, content, attachments...)
11831237 if err != nil {
11841238 isCancelErr := errors.Is(err, context.Canceled)
11851239 isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
11861240 if isCancelErr || isPermissionErr {
11871241 return nil
11881242 }
1189− return util.InfoMsg{
1190− Type: util.InfoTypeError,
1243+ return uiutil.InfoMsg{
1244+ Type: uiutil.InfoTypeError,
11911245 Msg: err.Error(),
11921246 }
11931247 }
internal/ui/styles/styles.go+179−3
@@ -85,7 +85,8 @@ type Styles struct {
8585 ItemOnlineIcon lipgloss.Style
8686
8787 // Markdown & Chroma
88− Markdown ansi.StyleConfig
88+ Markdown ansi.StyleConfig
89+ PlainMarkdown ansi.StyleConfig
8990
9091 // Inputs
9192 TextInput textinput.Styles
@@ -195,8 +196,14 @@ type Styles struct {
195196 Attachment lipgloss.Style
196197 ToolCallFocused lipgloss.Style
197198 ToolCallBlurred lipgloss.Style
198− ThinkingFooter lipgloss.Style
199199 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
206+ ThinkingFooterCancelled lipgloss.Style // "*Canceled*" text
200207 }
201208 }
202209
@@ -667,6 +674,169 @@ func DefaultStyles() Styles {
667674 },
668675 }
669676
677+ // PlainMarkdown style - muted colors on subtle background for thinking content.
678+ plainBg := stringPtr(bgBaseLighter.Hex())
679+ plainFg := stringPtr(fgMuted.Hex())
680+ s.PlainMarkdown = ansi.StyleConfig{
681+ Document: ansi.StyleBlock{
682+ StylePrimitive: ansi.StylePrimitive{
683+ Color: plainFg,
684+ BackgroundColor: plainBg,
685+ },
686+ },
687+ BlockQuote: ansi.StyleBlock{
688+ StylePrimitive: ansi.StylePrimitive{
689+ Color: plainFg,
690+ BackgroundColor: plainBg,
691+ },
692+ Indent: uintPtr(1),
693+ IndentToken: stringPtr("│ "),
694+ },
695+ List: ansi.StyleList{
696+ LevelIndent: defaultListIndent,
697+ },
698+ Heading: ansi.StyleBlock{
699+ StylePrimitive: ansi.StylePrimitive{
700+ BlockSuffix: "\n",
701+ Bold: boolPtr(true),
702+ Color: plainFg,
703+ BackgroundColor: plainBg,
704+ },
705+ },
706+ H1: ansi.StyleBlock{
707+ StylePrimitive: ansi.StylePrimitive{
708+ Prefix: " ",
709+ Suffix: " ",
710+ Bold: boolPtr(true),
711+ Color: plainFg,
712+ BackgroundColor: plainBg,
713+ },
714+ },
715+ H2: ansi.StyleBlock{
716+ StylePrimitive: ansi.StylePrimitive{
717+ Prefix: "## ",
718+ Color: plainFg,
719+ BackgroundColor: plainBg,
720+ },
721+ },
722+ H3: ansi.StyleBlock{
723+ StylePrimitive: ansi.StylePrimitive{
724+ Prefix: "### ",
725+ Color: plainFg,
726+ BackgroundColor: plainBg,
727+ },
728+ },
729+ H4: ansi.StyleBlock{
730+ StylePrimitive: ansi.StylePrimitive{
731+ Prefix: "#### ",
732+ Color: plainFg,
733+ BackgroundColor: plainBg,
734+ },
735+ },
736+ H5: ansi.StyleBlock{
737+ StylePrimitive: ansi.StylePrimitive{
738+ Prefix: "##### ",
739+ Color: plainFg,
740+ BackgroundColor: plainBg,
741+ },
742+ },
743+ H6: ansi.StyleBlock{
744+ StylePrimitive: ansi.StylePrimitive{
745+ Prefix: "###### ",
746+ Color: plainFg,
747+ BackgroundColor: plainBg,
748+ },
749+ },
750+ Strikethrough: ansi.StylePrimitive{
751+ CrossedOut: boolPtr(true),
752+ Color: plainFg,
753+ BackgroundColor: plainBg,
754+ },
755+ Emph: ansi.StylePrimitive{
756+ Italic: boolPtr(true),
757+ Color: plainFg,
758+ BackgroundColor: plainBg,
759+ },
760+ Strong: ansi.StylePrimitive{
761+ Bold: boolPtr(true),
762+ Color: plainFg,
763+ BackgroundColor: plainBg,
764+ },
765+ HorizontalRule: ansi.StylePrimitive{
766+ Format: "\n--------\n",
767+ Color: plainFg,
768+ BackgroundColor: plainBg,
769+ },
770+ Item: ansi.StylePrimitive{
771+ BlockPrefix: "• ",
772+ Color: plainFg,
773+ BackgroundColor: plainBg,
774+ },
775+ Enumeration: ansi.StylePrimitive{
776+ BlockPrefix: ". ",
777+ Color: plainFg,
778+ BackgroundColor: plainBg,
779+ },
780+ Task: ansi.StyleTask{
781+ StylePrimitive: ansi.StylePrimitive{
782+ Color: plainFg,
783+ BackgroundColor: plainBg,
784+ },
785+ Ticked: "[✓] ",
786+ Unticked: "[ ] ",
787+ },
788+ Link: ansi.StylePrimitive{
789+ Underline: boolPtr(true),
790+ Color: plainFg,
791+ BackgroundColor: plainBg,
792+ },
793+ LinkText: ansi.StylePrimitive{
794+ Bold: boolPtr(true),
795+ Color: plainFg,
796+ BackgroundColor: plainBg,
797+ },
798+ Image: ansi.StylePrimitive{
799+ Underline: boolPtr(true),
800+ Color: plainFg,
801+ BackgroundColor: plainBg,
802+ },
803+ ImageText: ansi.StylePrimitive{
804+ Format: "Image: {{.text}} →",
805+ Color: plainFg,
806+ BackgroundColor: plainBg,
807+ },
808+ Code: ansi.StyleBlock{
809+ StylePrimitive: ansi.StylePrimitive{
810+ Prefix: " ",
811+ Suffix: " ",
812+ Color: plainFg,
813+ BackgroundColor: plainBg,
814+ },
815+ },
816+ CodeBlock: ansi.StyleCodeBlock{
817+ StyleBlock: ansi.StyleBlock{
818+ StylePrimitive: ansi.StylePrimitive{
819+ Color: plainFg,
820+ BackgroundColor: plainBg,
821+ },
822+ Margin: uintPtr(defaultMargin),
823+ },
824+ },
825+ Table: ansi.StyleTable{
826+ StyleBlock: ansi.StyleBlock{
827+ StylePrimitive: ansi.StylePrimitive{
828+ Color: plainFg,
829+ BackgroundColor: plainBg,
830+ },
831+ },
832+ },
833+ DefinitionDescription: ansi.StylePrimitive{
834+ BlockPrefix: "\n ",
835+ Color: plainFg,
836+ BackgroundColor: plainBg,
837+ },
838+ }
839+
670840 s.Help = help.Styles{
671841 ShortKey: base.Foreground(fgMuted),
672842 ShortDesc: base.Foreground(fgSubtle),
@@ -892,9 +1062,15 @@ func DefaultStyles() Styles {
8921062 BorderLeft(true).
8931063 BorderForeground(greenDark)
8941064 s.Chat.Message.ToolCallBlurred = s.Muted.PaddingLeft(2)
895− s.Chat.Message.ThinkingFooter = s.Base
8961065 s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
8971066
1067+ // Thinking section styles
1068+ s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)
1069+ s.Chat.Message.ThinkingTruncationHint = s.Muted
1070+ s.Chat.Message.ThinkingFooterTitle = s.Muted
1071+ s.Chat.Message.ThinkingFooterDuration = s.Subtle
1072+ s.Chat.Message.ThinkingFooterCancelled = s.Subtle
1073+
8981074 // Text selection.
8991075 s.TextSelection = lipgloss.NewStyle().Foreground(charmtone.Salt).Background(charmtone.Charple)
9001076
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−}