on main help sign in
Merge pull request #1659 from charmbracelet/chat-ui-assistant-tools
8645586Ayman Bagabas
internal/ui/chat/assistant.go+2−5
@@ -152,13 +152,10 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
152 isTruncated := totalLines > maxCollapsedThinkingHeight
152 isTruncated := totalLines > maxCollapsedThinkingHeight
153 if !a.thinkingExpanded && isTruncated {
153 if !a.thinkingExpanded && isTruncated {
154 lines = lines[totalLines-maxCollapsedThinkingHeight:]
154 lines = lines[totalLines-maxCollapsedThinkingHeight:]
155− }
156−
157− if !a.thinkingExpanded && isTruncated {
158 hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
155 hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
159 fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
156 fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
160 )
157 )
161− lines = append([]string{hint}, lines...)
158+ lines = append(lines, "", hint)
162 }
159 }
163
160
164 thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
161 thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
@@ -167,7 +164,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
167
164
168 var footer string
165 var footer string
169 // if thinking is done add the thought for footer
166 // if thinking is done add the thought for footer
170− if !a.message.IsThinking() {
167+ if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
171 duration := a.message.ThinkingDuration()
168 duration := a.message.ThinkingDuration()
172 if duration.String() != "0s" {
169 if duration.String() != "0s" {
173 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
170 footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +
internal/ui/chat/bash.gonew file+108
@@ -0,0 +1,108 @@
1+package chat
2+
3+import (
4+ "encoding/json"
5+ "strings"
6+
7+ "github.com/charmbracelet/crush/internal/agent/tools"
8+ "github.com/charmbracelet/crush/internal/message"
9+ "github.com/charmbracelet/crush/internal/ui/styles"
10+)
11+
12+// BashToolMessageItem is a message item that represents a bash tool call.
13+type BashToolMessageItem struct {
14+ *baseToolMessageItem
15+}
16+
17+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
18+
19+// NewBashToolMessageItem creates a new [BashToolMessageItem].
20+func NewBashToolMessageItem(
21+ sty *styles.Styles,
22+ toolCall message.ToolCall,
23+ result *message.ToolResult,
24+ canceled bool,
25+) ToolMessageItem {
26+ return newBaseToolMessageItem(
27+ sty,
28+ toolCall,
29+ result,
30+ &BashToolRenderContext{},
31+ canceled,
32+ )
33+}
34+
35+// BashToolRenderContext holds context for rendering bash tool messages.
36+//
37+// It implements the [ToolRenderer] interface.
38+type BashToolRenderContext struct{}
39+
40+// RenderTool implements the [ToolRenderer] interface.
41+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
42+ cappedWidth := cappedMessageWidth(width)
43+ const toolName = "Bash"
44+ if !opts.ToolCall.Finished && !opts.Canceled {
45+ return pendingTool(sty, toolName, opts.Anim)
46+ }
47+
48+ var params tools.BashParams
49+ var cmd string
50+ err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
51+
52+ if err != nil {
53+ cmd = "failed to parse command"
54+ } else {
55+ cmd = strings.ReplaceAll(params.Command, "\n", " ")
56+ cmd = strings.ReplaceAll(cmd, "\t", " ")
57+ }
58+
59+ // TODO: if the tool is being run in the background use the background job renderer
60+
61+ toolParams := []string{
62+ cmd,
63+ }
64+
65+ if params.RunInBackground {
66+ toolParams = append(toolParams, "background", "true")
67+ }
68+
69+ header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
70+
71+ if opts.Nested {
72+ return header
73+ }
74+
75+ earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
76+
77+ // If this is OK that means that the tool is not done yet or it was canceled
78+ if ok {
79+ return strings.Join([]string{header, "", earlyStateContent}, "\n")
80+ }
81+
82+ if opts.Result == nil {
83+ // We should not get here!
84+ return header
85+ }
86+
87+ var meta tools.BashResponseMetadata
88+ err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
89+
90+ var output string
91+ if err != nil {
92+ output = "failed to parse output"
93+ }
94+ output = meta.Output
95+ if output == "" && opts.Result.Content != tools.BashNoOutput {
96+ output = opts.Result.Content
97+ }
98+
99+ if output == "" {
100+ return header
101+ }
102+
103+ bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
104+
105+ output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
106+
107+ return strings.Join([]string{header, "", output}, "\n")
108+}
internal/ui/chat/messages.go+15−3
@@ -150,12 +150,12 @@ func cappedMessageWidth(availableWidth int) int {
150 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
150 return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
151}
151}
152
152
153−// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
153+// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
154−// all parts of the message as [MessageItem]s.
154+// returns all parts of the message as [MessageItem]s.
155//
155//
156// For assistant messages with tool calls, pass a toolResults map to link results.
156// For assistant messages with tool calls, pass a toolResults map to link results.
157// Use BuildToolResultMap to create this map from all messages in a session.
157// Use BuildToolResultMap to create this map from all messages in a session.
158−func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
158+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
159 switch msg.Role {
159 switch msg.Role {
160 case message.User:
160 case message.User:
161 return []MessageItem{NewUserMessageItem(sty, msg)}
161 return []MessageItem{NewUserMessageItem(sty, msg)}
@@ -164,6 +164,18 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
164 if shouldRenderAssistantMessage(msg) {
164 if shouldRenderAssistantMessage(msg) {
165 items = append(items, NewAssistantMessageItem(sty, msg))
165 items = append(items, NewAssistantMessageItem(sty, msg))
166 }
166 }
167+ for _, tc := range msg.ToolCalls() {
168+ var result *message.ToolResult
169+ if tr, ok := toolResults[tc.ID]; ok {
170+ result = &tr
171+ }
172+ items = append(items, NewToolMessageItem(
173+ sty,
174+ tc,
175+ result,
176+ msg.FinishReason() == message.FinishReasonCanceled,
177+ ))
178+ }
167 return items
179 return items
168 }
180 }
169 return []MessageItem{}
181 return []MessageItem{}
internal/ui/chat/tools.gonew file+418
@@ -0,0 +1,418 @@
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/agent/tools"
10+ "github.com/charmbracelet/crush/internal/message"
11+ "github.com/charmbracelet/crush/internal/ui/anim"
12+ "github.com/charmbracelet/crush/internal/ui/styles"
13+ "github.com/charmbracelet/x/ansi"
14+)
15+
16+// responseContextHeight limits the number of lines displayed in tool output.
17+const responseContextHeight = 10
18+
19+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
20+const toolBodyLeftPaddingTotal = 2
21+
22+// ToolStatus represents the current state of a tool call.
23+type ToolStatus int
24+
25+const (
26+ ToolStatusAwaitingPermission ToolStatus = iota
27+ ToolStatusRunning
28+ ToolStatusSuccess
29+ ToolStatusError
30+ ToolStatusCanceled
31+)
32+
33+// ToolMessageItem represents a tool call message in the chat UI.
34+type ToolMessageItem interface {
35+ MessageItem
36+
37+ ToolCall() message.ToolCall
38+ SetToolCall(tc message.ToolCall)
39+ SetResult(res *message.ToolResult)
40+}
41+
42+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
43+type DefaultToolRenderContext struct{}
44+
45+// RenderTool implements the [ToolRenderer] interface.
46+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
47+ return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
48+}
49+
50+// ToolRenderOpts contains the data needed to render a tool call.
51+type ToolRenderOpts struct {
52+ ToolCall message.ToolCall
53+ Result *message.ToolResult
54+ Canceled bool
55+ Anim *anim.Anim
56+ Expanded bool
57+ Nested bool
58+ IsSpinning bool
59+ PermissionRequested bool
60+ PermissionGranted bool
61+}
62+
63+// Status returns the current status of the tool call.
64+func (opts *ToolRenderOpts) Status() ToolStatus {
65+ if opts.Canceled && opts.Result == nil {
66+ return ToolStatusCanceled
67+ }
68+ if opts.Result != nil {
69+ if opts.Result.IsError {
70+ return ToolStatusError
71+ }
72+ return ToolStatusSuccess
73+ }
74+ if opts.PermissionRequested && !opts.PermissionGranted {
75+ return ToolStatusAwaitingPermission
76+ }
77+ return ToolStatusRunning
78+}
79+
80+// ToolRenderer represents an interface for rendering tool calls.
81+type ToolRenderer interface {
82+ RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
83+}
84+
85+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
86+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
87+
88+// RenderTool implements the ToolRenderer interface.
89+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
90+ return f(sty, width, opts)
91+}
92+
93+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
94+type baseToolMessageItem struct {
95+ *highlightableMessageItem
96+ *cachedMessageItem
97+ *focusableMessageItem
98+
99+ toolRenderer ToolRenderer
100+ toolCall message.ToolCall
101+ result *message.ToolResult
102+ canceled bool
103+ permissionRequested bool
104+ permissionGranted bool
105+ // we use this so we can efficiently cache
106+ // tools that have a capped width (e.x bash.. and others)
107+ hasCappedWidth bool
108+
109+ sty *styles.Styles
110+ anim *anim.Anim
111+ expanded bool
112+}
113+
114+// newBaseToolMessageItem is the internal constructor for base tool message items.
115+func newBaseToolMessageItem(
116+ sty *styles.Styles,
117+ toolCall message.ToolCall,
118+ result *message.ToolResult,
119+ toolRenderer ToolRenderer,
120+ canceled bool,
121+) *baseToolMessageItem {
122+ // we only do full width for diffs (as far as I know)
123+ hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
124+
125+ t := &baseToolMessageItem{
126+ highlightableMessageItem: defaultHighlighter(sty),
127+ cachedMessageItem: &cachedMessageItem{},
128+ focusableMessageItem: &focusableMessageItem{},
129+ sty: sty,
130+ toolRenderer: toolRenderer,
131+ toolCall: toolCall,
132+ result: result,
133+ canceled: canceled,
134+ hasCappedWidth: hasCappedWidth,
135+ }
136+ t.anim = anim.New(anim.Settings{
137+ ID: toolCall.ID,
138+ Size: 15,
139+ GradColorA: sty.Primary,
140+ GradColorB: sty.Secondary,
141+ LabelColor: sty.FgBase,
142+ CycleColors: true,
143+ })
144+
145+ return t
146+}
147+
148+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
149+//
150+// It returns a specific tool message item type if implemented, otherwise it
151+// returns a generic tool message item.
152+func NewToolMessageItem(
153+ sty *styles.Styles,
154+ toolCall message.ToolCall,
155+ result *message.ToolResult,
156+ canceled bool,
157+) ToolMessageItem {
158+ switch toolCall.Name {
159+ case tools.BashToolName:
160+ return NewBashToolMessageItem(sty, toolCall, result, canceled)
161+ default:
162+ // TODO: Implement other tool items
163+ return newBaseToolMessageItem(
164+ sty,
165+ toolCall,
166+ result,
167+ &DefaultToolRenderContext{},
168+ canceled,
169+ )
170+ }
171+}
172+
173+// ID returns the unique identifier for this tool message item.
174+func (t *baseToolMessageItem) ID() string {
175+ return t.toolCall.ID
176+}
177+
178+// StartAnimation starts the assistant message animation if it should be spinning.
179+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
180+ if !t.isSpinning() {
181+ return nil
182+ }
183+ return t.anim.Start()
184+}
185+
186+// Animate progresses the assistant message animation if it should be spinning.
187+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
188+ if !t.isSpinning() {
189+ return nil
190+ }
191+ return t.anim.Animate(msg)
192+}
193+
194+// Render renders the tool message item at the given width.
195+func (t *baseToolMessageItem) Render(width int) string {
196+ toolItemWidth := width - messageLeftPaddingTotal
197+ if t.hasCappedWidth {
198+ toolItemWidth = cappedMessageWidth(width)
199+ }
200+ style := t.sty.Chat.Message.ToolCallBlurred
201+ if t.focused {
202+ style = t.sty.Chat.Message.ToolCallFocused
203+ }
204+
205+ content, height, ok := t.getCachedRender(toolItemWidth)
206+ // if we are spinning or there is no cache rerender
207+ if !ok || t.isSpinning() {
208+ content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
209+ ToolCall: t.toolCall,
210+ Result: t.result,
211+ Canceled: t.canceled,
212+ Anim: t.anim,
213+ Expanded: t.expanded,
214+ PermissionRequested: t.permissionRequested,
215+ PermissionGranted: t.permissionGranted,
216+ IsSpinning: t.isSpinning(),
217+ })
218+ height = lipgloss.Height(content)
219+ // cache the rendered content
220+ t.setCachedRender(content, toolItemWidth, height)
221+ }
222+
223+ highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
224+ return style.Render(highlightedContent)
225+}
226+
227+// ToolCall returns the tool call associated with this message item.
228+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
229+ return t.toolCall
230+}
231+
232+// SetToolCall sets the tool call associated with this message item.
233+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
234+ t.toolCall = tc
235+ t.clearCache()
236+}
237+
238+// SetResult sets the tool result associated with this message item.
239+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
240+ t.result = res
241+ t.clearCache()
242+}
243+
244+// SetPermissionRequested sets whether permission has been requested for this tool call.
245+// TODO: Consider merging with SetPermissionGranted and add an interface for
246+// permission management.
247+func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
248+ t.permissionRequested = requested
249+ t.clearCache()
250+}
251+
252+// SetPermissionGranted sets whether permission has been granted for this tool call.
253+// TODO: Consider merging with SetPermissionRequested and add an interface for
254+// permission management.
255+func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
256+ t.permissionGranted = granted
257+ t.clearCache()
258+}
259+
260+// isSpinning returns true if the tool should show animation.
261+func (t *baseToolMessageItem) isSpinning() bool {
262+ return !t.toolCall.Finished && !t.canceled
263+}
264+
265+// ToggleExpanded toggles the expanded state of the thinking box.
266+func (t *baseToolMessageItem) ToggleExpanded() {
267+ t.expanded = !t.expanded
268+ t.clearCache()
269+}
270+
271+// HandleMouseClick implements MouseClickable.
272+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
273+ if btn != ansi.MouseLeft {
274+ return false
275+ }
276+ t.ToggleExpanded()
277+ return true
278+}
279+
280+// pendingTool renders a tool that is still in progress with an animation.
281+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
282+ icon := sty.Tool.IconPending.Render()
283+ toolName := sty.Tool.NameNormal.Render(name)
284+
285+ var animView string
286+ if anim != nil {
287+ animView = anim.Render()
288+ }
289+
290+ return fmt.Sprintf("%s %s %s", icon, toolName, animView)
291+}
292+
293+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
294+// Returns the rendered output and true if early state was handled.
295+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
296+ var msg string
297+ switch opts.Status() {
298+ case ToolStatusError:
299+ msg = toolErrorContent(sty, opts.Result, width)
300+ case ToolStatusCanceled:
301+ msg = sty.Tool.StateCancelled.Render("Canceled.")
302+ case ToolStatusAwaitingPermission:
303+ msg = sty.Tool.StateWaiting.Render("Requesting permission...")
304+ case ToolStatusRunning:
305+ msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
306+ default:
307+ return "", false
308+ }
309+ return msg, true
310+}
311+
312+// toolErrorContent formats an error message with ERROR tag.
313+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
314+ if result == nil {
315+ return ""
316+ }
317+ errContent := strings.ReplaceAll(result.Content, "\n", " ")
318+ errTag := sty.Tool.ErrorTag.Render("ERROR")
319+ tagWidth := lipgloss.Width(errTag)
320+ errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
321+ return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
322+}
323+
324+// toolIcon returns the status icon for a tool call.
325+// toolIcon returns the status icon for a tool call based on its status.
326+func toolIcon(sty *styles.Styles, status ToolStatus) string {
327+ switch status {
328+ case ToolStatusSuccess:
329+ return sty.Tool.IconSuccess.String()
330+ case ToolStatusError:
331+ return sty.Tool.IconError.String()
332+ case ToolStatusCanceled:
333+ return sty.Tool.IconCancelled.String()
334+ default:
335+ return sty.Tool.IconPending.String()
336+ }
337+}
338+
339+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
340+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
341+func toolParamList(sty *styles.Styles, params []string, width int) string {
342+ // minSpaceForMainParam is the min space required for the main param
343+ // if this is less that the value set we will only show the main param nothing else
344+ const minSpaceForMainParam = 30
345+ if len(params) == 0 {
346+ return ""
347+ }
348+
349+ mainParam := params[0]
350+
351+ // Build key=value pairs from remaining params (consecutive key, value pairs).
352+ var kvPairs []string
353+ for i := 1; i+1 < len(params); i += 2 {
354+ if params[i+1] != "" {
355+ kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
356+ }
357+ }
358+
359+ // Try to include key=value pairs if there's enough space.
360+ output := mainParam
361+ if len(kvPairs) > 0 {
362+ partsStr := strings.Join(kvPairs, ", ")
363+ if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
364+ output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
365+ }
366+ }
367+
368+ if width >= 0 {
369+ output = ansi.Truncate(output, width, "…")
370+ }
371+ return sty.Tool.ParamMain.Render(output)
372+}
373+
374+// toolHeader builds the tool header line: "● ToolName params..."
375+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
376+ icon := toolIcon(sty, status)
377+ toolName := sty.Tool.NameNested.Render(name)
378+ prefix := fmt.Sprintf("%s %s ", icon, toolName)
379+ prefixWidth := lipgloss.Width(prefix)
380+ remainingWidth := width - prefixWidth
381+ paramsStr := toolParamList(sty, params, remainingWidth)
382+ return prefix + paramsStr
383+}
384+
385+// toolOutputPlainContent renders plain text with optional expansion support.
386+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
387+ content = strings.ReplaceAll(content, "\r\n", "\n")
388+ content = strings.ReplaceAll(content, "\t", " ")
389+ content = strings.TrimSpace(content)
390+ lines := strings.Split(content, "\n")
391+
392+ maxLines := responseContextHeight
393+ if expanded {
394+ maxLines = len(lines) // Show all
395+ }
396+
397+ var out []string
398+ for i, ln := range lines {
399+ if i >= maxLines {
400+ break
401+ }
402+ ln = " " + ln
403+ if lipgloss.Width(ln) > width {
404+ ln = ansi.Truncate(ln, width, "…")
405+ }
406+ out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
407+ }
408+
409+ wasTruncated := len(lines) > responseContextHeight
410+
411+ if !expanded && wasTruncated {
412+ out = append(out, sty.Tool.ContentTruncation.
413+ Width(width).
414+ Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
415+ }
416+
417+ return strings.Join(out, "\n")
418+}
internal/ui/model/chat.go+10−2
@@ -237,8 +237,16 @@ func (m *Chat) SelectLastInView() {
237 m.list.SelectLastInView()
237 m.list.SelectLastInView()
238}
238}
239
239
240+// ClearMessages removes all messages from the chat list.
241+func (m *Chat) ClearMessages() {
242+ m.idInxMap = make(map[string]int)
243+ m.pausedAnimations = make(map[string]struct{})
244+ m.list.SetItems()
245+ m.ClearMouse()
246+}
247+
240−// GetMessageItem returns the message item at the given id.
248+// MessageItem returns the message item with the given ID, or nil if not found.
241−func (m *Chat) GetMessageItem(id string) chat.MessageItem {
249+func (m *Chat) MessageItem(id string) chat.MessageItem {
242 idx, ok := m.idInxMap[id]
250 idx, ok := m.idInxMap[id]
243 if !ok {
251 if !ok {
244 return nil
252 return nil
internal/ui/model/ui.go+95−18
@@ -208,6 +208,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
208 }
208 }
209
209
210 case pubsub.Event[message.Message]:
210 case pubsub.Event[message.Message]:
211+ // TODO: handle nested messages for agentic tools
211 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212 if m.session == nil || msg.Payload.SessionID != m.session.ID {
212 break
213 break
213 }
214 }
@@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
217 case pubsub.UpdatedEvent:
218 case pubsub.UpdatedEvent:
218 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219 cmds = append(cmds, m.updateSessionMessage(msg.Payload))
219 }
220 }
220− // TODO: Finish implementing me
221− // cmds = append(cmds, m.setMessageEvents(msg.Payload))
222 case pubsub.Event[history.File]:
221 case pubsub.Event[history.File]:
223 cmds = append(cmds, m.handleFileEvent(msg.Payload))
222 cmds = append(cmds, m.handleFileEvent(msg.Payload))
224 case pubsub.Event[app.LSPEvent]:
223 case pubsub.Event[app.LSPEvent]:
@@ -381,7 +380,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
381 // Add messages to chat with linked tool results
380 // Add messages to chat with linked tool results
382 items := make([]chat.MessageItem, 0, len(msgs)*2)
381 items := make([]chat.MessageItem, 0, len(msgs)*2)
383 for _, msg := range msgPtrs {
382 for _, msg := range msgPtrs {
384− items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
383+ items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
385 }
384 }
386
385
387 // If the user switches between sessions while the agent is working we want
386 // If the user switches between sessions while the agent is working we want
@@ -403,9 +402,68 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
403}
402}
404
403
405// appendSessionMessage appends a new message to the current session in the chat
404// appendSessionMessage appends a new message to the current session in the chat
405+// if the message is a tool result it will update the corresponding tool call message
406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
406func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
407− items := chat.GetMessageItems(m.com.Styles, &msg, nil)
408 var cmds []tea.Cmd
407 var cmds []tea.Cmd
408+ switch msg.Role {
409+ case message.User, message.Assistant:
410+ items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
411+ for _, item := range items {
412+ if animatable, ok := item.(chat.Animatable); ok {
413+ if cmd := animatable.StartAnimation(); cmd != nil {
414+ cmds = append(cmds, cmd)
415+ }
416+ }
417+ }
418+ m.chat.AppendMessages(items...)
419+ if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
420+ cmds = append(cmds, cmd)
421+ }
422+ case message.Tool:
423+ for _, tr := range msg.ToolResults() {
424+ toolItem := m.chat.MessageItem(tr.ToolCallID)
425+ if toolItem == nil {
426+ // we should have an item!
427+ continue
428+ }
429+ if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
430+ toolMsgItem.SetResult(&tr)
431+ }
432+ }
433+ }
434+ return tea.Batch(cmds...)
435+}
436+
437+// updateSessionMessage updates an existing message in the current session in the chat
438+// when an assistant message is updated it may include updated tool calls as well
439+// that is why we need to handle creating/updating each tool call message too
440+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
441+ var cmds []tea.Cmd
442+ existingItem := m.chat.MessageItem(msg.ID)
443+ if existingItem == nil || msg.Role != message.Assistant {
444+ return nil
445+ }
446+
447+ if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
448+ assistantItem.SetMessage(&msg)
449+ }
450+
451+ var items []chat.MessageItem
452+ for _, tc := range msg.ToolCalls() {
453+ existingToolItem := m.chat.MessageItem(tc.ID)
454+ if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
455+ existingToolCall := toolItem.ToolCall()
456+ // only update if finished state changed or input changed
457+ // to avoid clearing the cache
458+ if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
459+ toolItem.SetToolCall(tc)
460+ }
461+ }
462+ if existingToolItem == nil {
463+ items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
464+ }
465+ }
466+
409 for _, item := range items {
467 for _, item := range items {
410 if animatable, ok := item.(chat.Animatable); ok {
468 if animatable, ok := item.(chat.Animatable); ok {
411 if cmd := animatable.StartAnimation(); cmd != nil {
469 if cmd := animatable.StartAnimation(); cmd != nil {
@@ -417,21 +475,8 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
417 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
475 if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
418 cmds = append(cmds, cmd)
476 cmds = append(cmds, cmd)
419 }
477 }
420− return tea.Batch(cmds...)
421−}
422
478
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
479+ return tea.Batch(cmds...)
435}
480}
436
481
437func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
482func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
498 case dialog.SwitchSessionsMsg:
543 case dialog.SwitchSessionsMsg:
499 cmds = append(cmds, m.listSessions)
544 cmds = append(cmds, m.listSessions)
500 m.dialog.CloseDialog(dialog.CommandsID)
545 m.dialog.CloseDialog(dialog.CommandsID)
546+ case dialog.NewSessionsMsg:
547+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
548+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
549+ break
550+ }
551+ m.newSession()
552+ m.dialog.CloseDialog(dialog.CommandsID)
501 case dialog.CompactMsg:
553 case dialog.CompactMsg:
502 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
554 err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
503 if err != nil {
555 if err != nil {
@@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
548 m.randomizePlaceholders()
600 m.randomizePlaceholders()
549
601
550 return m.sendMessage(value, attachments)
602 return m.sendMessage(value, attachments)
603+ case key.Matches(msg, m.keyMap.Chat.NewSession):
604+ if m.session == nil || m.session.ID == "" {
605+ break
606+ }
607+ if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
608+ cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
609+ break
610+ }
611+ m.newSession()
551 case key.Matches(msg, m.keyMap.Tab):
612 case key.Matches(msg, m.keyMap.Tab):
552 m.focus = uiFocusMain
613 m.focus = uiFocusMain
553 m.textarea.Blur()
614 m.textarea.Blur()
@@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg {
1362 return listSessionsMsg{sessions: allSessions}
1423 return listSessionsMsg{sessions: allSessions}
1363}
1424}
1364
1425
1426+// newSession clears the current session state and prepares for a new session.
1427+// The actual session creation happens when the user sends their first message.
1428+func (m *UI) newSession() {
1429+ if m.session == nil || m.session.ID == "" {
1430+ return
1431+ }
1432+
1433+ m.session = nil
1434+ m.sessionFiles = nil
1435+ m.state = uiLanding
1436+ m.focus = uiFocusEditor
1437+ m.textarea.Focus()
1438+ m.chat.Blur()
1439+ m.chat.ClearMessages()
1440+}
1441+
1365// handlePasteMsg handles a paste message.
1442// handlePasteMsg handles a paste message.
1366func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1443func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
1367 if m.focus != uiFocusEditor {
1444 if m.focus != uiFocusEditor {
internal/ui/styles/styles.go+8−2
@@ -26,6 +26,8 @@ const (
26 DocumentIcon string = "🖼"
26 DocumentIcon string = "🖼"
27 ModelIcon string = "◇"
27 ModelIcon string = "◇"
28
28
29+ ArrowRightIcon string = "→"
30+
29 ToolPending string = "●"
31 ToolPending string = "●"
30 ToolSuccess string = "✓"
32 ToolSuccess string = "✓"
31 ToolError string = "×"
33 ToolError string = "×"
@@ -34,6 +36,10 @@ const (
34 BorderThick string = "▌"
36 BorderThick string = "▌"
35
37
36 SectionSeparator string = "─"
38 SectionSeparator string = "─"
39+
40+ TodoCompletedIcon string = "✓"
41+ TodoPendingIcon string = "•"
42+ TodoInProgressIcon string = "→"
37)
43)
38
44
39const (
45const (
@@ -227,7 +233,7 @@ type Styles struct {
227 ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
233 ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
228 ContentCodeLine lipgloss.Style // Code line with background and width
234 ContentCodeLine lipgloss.Style // Code line with background and width
229 ContentCodeBg color.Color // Background color for syntax highlighting
235 ContentCodeBg color.Color // Background color for syntax highlighting
230− BodyPadding lipgloss.Style // Body content padding (PaddingLeft(2))
236+ Body lipgloss.Style // Body content padding (PaddingLeft(2))
231
237
232 // Deprecated - kept for backward compatibility
238 // Deprecated - kept for backward compatibility
233 ContentBg lipgloss.Style // Content background
239 ContentBg lipgloss.Style // Content background
@@ -956,7 +962,7 @@ func DefaultStyles() Styles {
956 s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
962 s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
957 s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
963 s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
958 s.Tool.ContentCodeBg = bgBase
964 s.Tool.ContentCodeBg = bgBase
959− s.Tool.BodyPadding = base.PaddingLeft(2)
965+ s.Tool.Body = base.PaddingLeft(2)
960
966
961 // Deprecated - kept for backward compatibility
967 // Deprecated - kept for backward compatibility
962 s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)
968 s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)