Merge pull request #1659 from charmbracelet/chat-ui-assistant-tools

Ayman Bagabas created

refactor(chat): initial setup for tool calls

Change summary

internal/ui/chat/assistant.go |   7 
internal/ui/chat/bash.go      | 108 +++++++++
internal/ui/chat/messages.go  |  18 +
internal/ui/chat/tools.go     | 418 +++++++++++++++++++++++++++++++++++++
internal/ui/model/chat.go     |  12 
internal/ui/model/ui.go       | 113 ++++++++-
internal/ui/styles/styles.go  |  10 
7 files changed, 656 insertions(+), 30 deletions(-)

Detailed changes

internal/ui/chat/assistant.go 🔗

@@ -152,13 +152,10 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
 	isTruncated := totalLines > maxCollapsedThinkingHeight
 	if !a.thinkingExpanded && isTruncated {
 		lines = lines[totalLines-maxCollapsedThinkingHeight:]
-	}
-
-	if !a.thinkingExpanded && isTruncated {
 		hint := a.sty.Chat.Message.ThinkingTruncationHint.Render(
 			fmt.Sprintf("… (%d lines hidden) [click or space to expand]", totalLines-maxCollapsedThinkingHeight),
 		)
-		lines = append([]string{hint}, lines...)
+		lines = append(lines, "", hint)
 	}
 
 	thinkingStyle := a.sty.Chat.Message.ThinkingBox.Width(width)
@@ -167,7 +164,7 @@ func (a *AssistantMessageItem) renderThinking(thinking string, width int) string
 
 	var footer string
 	// if thinking is done add the thought for footer
-	if !a.message.IsThinking() {
+	if !a.message.IsThinking() || len(a.message.ToolCalls()) > 0 {
 		duration := a.message.ThinkingDuration()
 		if duration.String() != "0s" {
 			footer = a.sty.Chat.Message.ThinkingFooterTitle.Render("Thought for ") +

internal/ui/chat/bash.go 🔗

@@ -0,0 +1,108 @@
+package chat
+
+import (
+	"encoding/json"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+)
+
+// BashToolMessageItem is a message item that represents a bash tool call.
+type BashToolMessageItem struct {
+	*baseToolMessageItem
+}
+
+var _ ToolMessageItem = (*BashToolMessageItem)(nil)
+
+// NewBashToolMessageItem creates a new [BashToolMessageItem].
+func NewBashToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	return newBaseToolMessageItem(
+		sty,
+		toolCall,
+		result,
+		&BashToolRenderContext{},
+		canceled,
+	)
+}
+
+// BashToolRenderContext holds context for rendering bash tool messages.
+//
+// It implements the [ToolRenderer] interface.
+type BashToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (b *BashToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	cappedWidth := cappedMessageWidth(width)
+	const toolName = "Bash"
+	if !opts.ToolCall.Finished && !opts.Canceled {
+		return pendingTool(sty, toolName, opts.Anim)
+	}
+
+	var params tools.BashParams
+	var cmd string
+	err := json.Unmarshal([]byte(opts.ToolCall.Input), &params)
+
+	if err != nil {
+		cmd = "failed to parse command"
+	} else {
+		cmd = strings.ReplaceAll(params.Command, "\n", " ")
+		cmd = strings.ReplaceAll(cmd, "\t", "    ")
+	}
+
+	// TODO: if the tool is being run in the background use the background job renderer
+
+	toolParams := []string{
+		cmd,
+	}
+
+	if params.RunInBackground {
+		toolParams = append(toolParams, "background", "true")
+	}
+
+	header := toolHeader(sty, opts.Status(), "Bash", cappedWidth, toolParams...)
+
+	if opts.Nested {
+		return header
+	}
+
+	earlyStateContent, ok := toolEarlyStateContent(sty, opts, cappedWidth)
+
+	// If this is OK that means that the tool is not done yet or it was canceled
+	if ok {
+		return strings.Join([]string{header, "", earlyStateContent}, "\n")
+	}
+
+	if opts.Result == nil {
+		// We should not get here!
+		return header
+	}
+
+	var meta tools.BashResponseMetadata
+	err = json.Unmarshal([]byte(opts.Result.Metadata), &meta)
+
+	var output string
+	if err != nil {
+		output = "failed to parse output"
+	}
+	output = meta.Output
+	if output == "" && opts.Result.Content != tools.BashNoOutput {
+		output = opts.Result.Content
+	}
+
+	if output == "" {
+		return header
+	}
+
+	bodyWidth := cappedWidth - toolBodyLeftPaddingTotal
+
+	output = sty.Tool.Body.Render(toolOutputPlainContent(sty, output, bodyWidth, opts.Expanded))
+
+	return strings.Join([]string{header, "", output}, "\n")
+}

internal/ui/chat/messages.go 🔗

@@ -150,12 +150,12 @@ func cappedMessageWidth(availableWidth int) int {
 	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)
 }
 
-// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns
-// all parts of the message as [MessageItem]s.
+// ExtractMessageItems extracts [MessageItem]s from a [message.Message]. It
+// returns all parts of the message as [MessageItem]s.
 //
 // For assistant messages with tool calls, pass a toolResults map to link results.
 // Use BuildToolResultMap to create this map from all messages in a session.
-func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
+func ExtractMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem {
 	switch msg.Role {
 	case message.User:
 		return []MessageItem{NewUserMessageItem(sty, msg)}
@@ -164,6 +164,18 @@ func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[s
 		if shouldRenderAssistantMessage(msg) {
 			items = append(items, NewAssistantMessageItem(sty, msg))
 		}
+		for _, tc := range msg.ToolCalls() {
+			var result *message.ToolResult
+			if tr, ok := toolResults[tc.ID]; ok {
+				result = &tr
+			}
+			items = append(items, NewToolMessageItem(
+				sty,
+				tc,
+				result,
+				msg.FinishReason() == message.FinishReasonCanceled,
+			))
+		}
 		return items
 	}
 	return []MessageItem{}

internal/ui/chat/tools.go 🔗

@@ -0,0 +1,418 @@
+package chat
+
+import (
+	"fmt"
+	"strings"
+
+	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/crush/internal/agent/tools"
+	"github.com/charmbracelet/crush/internal/message"
+	"github.com/charmbracelet/crush/internal/ui/anim"
+	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
+)
+
+// responseContextHeight limits the number of lines displayed in tool output.
+const responseContextHeight = 10
+
+// toolBodyLeftPaddingTotal represents the padding that should be applied to each tool body
+const toolBodyLeftPaddingTotal = 2
+
+// ToolStatus represents the current state of a tool call.
+type ToolStatus int
+
+const (
+	ToolStatusAwaitingPermission ToolStatus = iota
+	ToolStatusRunning
+	ToolStatusSuccess
+	ToolStatusError
+	ToolStatusCanceled
+)
+
+// ToolMessageItem represents a tool call message in the chat UI.
+type ToolMessageItem interface {
+	MessageItem
+
+	ToolCall() message.ToolCall
+	SetToolCall(tc message.ToolCall)
+	SetResult(res *message.ToolResult)
+}
+
+// DefaultToolRenderContext implements the default [ToolRenderer] interface.
+type DefaultToolRenderContext struct{}
+
+// RenderTool implements the [ToolRenderer] interface.
+func (d *DefaultToolRenderContext) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return "TODO: Implement Tool Renderer For: " + opts.ToolCall.Name
+}
+
+// ToolRenderOpts contains the data needed to render a tool call.
+type ToolRenderOpts struct {
+	ToolCall            message.ToolCall
+	Result              *message.ToolResult
+	Canceled            bool
+	Anim                *anim.Anim
+	Expanded            bool
+	Nested              bool
+	IsSpinning          bool
+	PermissionRequested bool
+	PermissionGranted   bool
+}
+
+// Status returns the current status of the tool call.
+func (opts *ToolRenderOpts) Status() ToolStatus {
+	if opts.Canceled && opts.Result == nil {
+		return ToolStatusCanceled
+	}
+	if opts.Result != nil {
+		if opts.Result.IsError {
+			return ToolStatusError
+		}
+		return ToolStatusSuccess
+	}
+	if opts.PermissionRequested && !opts.PermissionGranted {
+		return ToolStatusAwaitingPermission
+	}
+	return ToolStatusRunning
+}
+
+// ToolRenderer represents an interface for rendering tool calls.
+type ToolRenderer interface {
+	RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+}
+
+// ToolRendererFunc is a function type that implements the [ToolRenderer] interface.
+type ToolRendererFunc func(sty *styles.Styles, width int, opts *ToolRenderOpts) string
+
+// RenderTool implements the ToolRenderer interface.
+func (f ToolRendererFunc) RenderTool(sty *styles.Styles, width int, opts *ToolRenderOpts) string {
+	return f(sty, width, opts)
+}
+
+// baseToolMessageItem represents a tool call message that can be displayed in the UI.
+type baseToolMessageItem struct {
+	*highlightableMessageItem
+	*cachedMessageItem
+	*focusableMessageItem
+
+	toolRenderer        ToolRenderer
+	toolCall            message.ToolCall
+	result              *message.ToolResult
+	canceled            bool
+	permissionRequested bool
+	permissionGranted   bool
+	// we use this so we can efficiently cache
+	// tools that have a capped width (e.x bash.. and others)
+	hasCappedWidth bool
+
+	sty      *styles.Styles
+	anim     *anim.Anim
+	expanded bool
+}
+
+// newBaseToolMessageItem is the internal constructor for base tool message items.
+func newBaseToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	toolRenderer ToolRenderer,
+	canceled bool,
+) *baseToolMessageItem {
+	// we only do full width for diffs (as far as I know)
+	hasCappedWidth := toolCall.Name != tools.EditToolName && toolCall.Name != tools.MultiEditToolName
+
+	t := &baseToolMessageItem{
+		highlightableMessageItem: defaultHighlighter(sty),
+		cachedMessageItem:        &cachedMessageItem{},
+		focusableMessageItem:     &focusableMessageItem{},
+		sty:                      sty,
+		toolRenderer:             toolRenderer,
+		toolCall:                 toolCall,
+		result:                   result,
+		canceled:                 canceled,
+		hasCappedWidth:           hasCappedWidth,
+	}
+	t.anim = anim.New(anim.Settings{
+		ID:          toolCall.ID,
+		Size:        15,
+		GradColorA:  sty.Primary,
+		GradColorB:  sty.Secondary,
+		LabelColor:  sty.FgBase,
+		CycleColors: true,
+	})
+
+	return t
+}
+
+// NewToolMessageItem creates a new [ToolMessageItem] based on the tool call name.
+//
+// It returns a specific tool message item type if implemented, otherwise it
+// returns a generic tool message item.
+func NewToolMessageItem(
+	sty *styles.Styles,
+	toolCall message.ToolCall,
+	result *message.ToolResult,
+	canceled bool,
+) ToolMessageItem {
+	switch toolCall.Name {
+	case tools.BashToolName:
+		return NewBashToolMessageItem(sty, toolCall, result, canceled)
+	default:
+		// TODO: Implement other tool items
+		return newBaseToolMessageItem(
+			sty,
+			toolCall,
+			result,
+			&DefaultToolRenderContext{},
+			canceled,
+		)
+	}
+}
+
+// ID returns the unique identifier for this tool message item.
+func (t *baseToolMessageItem) ID() string {
+	return t.toolCall.ID
+}
+
+// StartAnimation starts the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) StartAnimation() tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Start()
+}
+
+// Animate progresses the assistant message animation if it should be spinning.
+func (t *baseToolMessageItem) Animate(msg anim.StepMsg) tea.Cmd {
+	if !t.isSpinning() {
+		return nil
+	}
+	return t.anim.Animate(msg)
+}
+
+// Render renders the tool message item at the given width.
+func (t *baseToolMessageItem) Render(width int) string {
+	toolItemWidth := width - messageLeftPaddingTotal
+	if t.hasCappedWidth {
+		toolItemWidth = cappedMessageWidth(width)
+	}
+	style := t.sty.Chat.Message.ToolCallBlurred
+	if t.focused {
+		style = t.sty.Chat.Message.ToolCallFocused
+	}
+
+	content, height, ok := t.getCachedRender(toolItemWidth)
+	// if we are spinning or there is no cache rerender
+	if !ok || t.isSpinning() {
+		content = t.toolRenderer.RenderTool(t.sty, toolItemWidth, &ToolRenderOpts{
+			ToolCall:            t.toolCall,
+			Result:              t.result,
+			Canceled:            t.canceled,
+			Anim:                t.anim,
+			Expanded:            t.expanded,
+			PermissionRequested: t.permissionRequested,
+			PermissionGranted:   t.permissionGranted,
+			IsSpinning:          t.isSpinning(),
+		})
+		height = lipgloss.Height(content)
+		// cache the rendered content
+		t.setCachedRender(content, toolItemWidth, height)
+	}
+
+	highlightedContent := t.renderHighlighted(content, toolItemWidth, height)
+	return style.Render(highlightedContent)
+}
+
+// ToolCall returns the tool call associated with this message item.
+func (t *baseToolMessageItem) ToolCall() message.ToolCall {
+	return t.toolCall
+}
+
+// SetToolCall sets the tool call associated with this message item.
+func (t *baseToolMessageItem) SetToolCall(tc message.ToolCall) {
+	t.toolCall = tc
+	t.clearCache()
+}
+
+// SetResult sets the tool result associated with this message item.
+func (t *baseToolMessageItem) SetResult(res *message.ToolResult) {
+	t.result = res
+	t.clearCache()
+}
+
+// SetPermissionRequested sets whether permission has been requested for this tool call.
+// TODO: Consider merging with SetPermissionGranted and add an interface for
+// permission management.
+func (t *baseToolMessageItem) SetPermissionRequested(requested bool) {
+	t.permissionRequested = requested
+	t.clearCache()
+}
+
+// SetPermissionGranted sets whether permission has been granted for this tool call.
+// TODO: Consider merging with SetPermissionRequested and add an interface for
+// permission management.
+func (t *baseToolMessageItem) SetPermissionGranted(granted bool) {
+	t.permissionGranted = granted
+	t.clearCache()
+}
+
+// isSpinning returns true if the tool should show animation.
+func (t *baseToolMessageItem) isSpinning() bool {
+	return !t.toolCall.Finished && !t.canceled
+}
+
+// ToggleExpanded toggles the expanded state of the thinking box.
+func (t *baseToolMessageItem) ToggleExpanded() {
+	t.expanded = !t.expanded
+	t.clearCache()
+}
+
+// HandleMouseClick implements MouseClickable.
+func (t *baseToolMessageItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
+	if btn != ansi.MouseLeft {
+		return false
+	}
+	t.ToggleExpanded()
+	return true
+}
+
+// pendingTool renders a tool that is still in progress with an animation.
+func pendingTool(sty *styles.Styles, name string, anim *anim.Anim) string {
+	icon := sty.Tool.IconPending.Render()
+	toolName := sty.Tool.NameNormal.Render(name)
+
+	var animView string
+	if anim != nil {
+		animView = anim.Render()
+	}
+
+	return fmt.Sprintf("%s %s %s", icon, toolName, animView)
+}
+
+// toolEarlyStateContent handles error/cancelled/pending states before content rendering.
+// Returns the rendered output and true if early state was handled.
+func toolEarlyStateContent(sty *styles.Styles, opts *ToolRenderOpts, width int) (string, bool) {
+	var msg string
+	switch opts.Status() {
+	case ToolStatusError:
+		msg = toolErrorContent(sty, opts.Result, width)
+	case ToolStatusCanceled:
+		msg = sty.Tool.StateCancelled.Render("Canceled.")
+	case ToolStatusAwaitingPermission:
+		msg = sty.Tool.StateWaiting.Render("Requesting permission...")
+	case ToolStatusRunning:
+		msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
+	default:
+		return "", false
+	}
+	return msg, true
+}
+
+// toolErrorContent formats an error message with ERROR tag.
+func toolErrorContent(sty *styles.Styles, result *message.ToolResult, width int) string {
+	if result == nil {
+		return ""
+	}
+	errContent := strings.ReplaceAll(result.Content, "\n", " ")
+	errTag := sty.Tool.ErrorTag.Render("ERROR")
+	tagWidth := lipgloss.Width(errTag)
+	errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
+	return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
+}
+
+// toolIcon returns the status icon for a tool call.
+// toolIcon returns the status icon for a tool call based on its status.
+func toolIcon(sty *styles.Styles, status ToolStatus) string {
+	switch status {
+	case ToolStatusSuccess:
+		return sty.Tool.IconSuccess.String()
+	case ToolStatusError:
+		return sty.Tool.IconError.String()
+	case ToolStatusCanceled:
+		return sty.Tool.IconCancelled.String()
+	default:
+		return sty.Tool.IconPending.String()
+	}
+}
+
+// toolParamList formats parameters as "main (key=value, ...)" with truncation.
+// toolParamList formats tool parameters as "main (key=value, ...)" with truncation.
+func toolParamList(sty *styles.Styles, params []string, width int) string {
+	// minSpaceForMainParam is the min space required for the main param
+	// if this is less that the value set we will only show the main param nothing else
+	const minSpaceForMainParam = 30
+	if len(params) == 0 {
+		return ""
+	}
+
+	mainParam := params[0]
+
+	// Build key=value pairs from remaining params (consecutive key, value pairs).
+	var kvPairs []string
+	for i := 1; i+1 < len(params); i += 2 {
+		if params[i+1] != "" {
+			kvPairs = append(kvPairs, fmt.Sprintf("%s=%s", params[i], params[i+1]))
+		}
+	}
+
+	// Try to include key=value pairs if there's enough space.
+	output := mainParam
+	if len(kvPairs) > 0 {
+		partsStr := strings.Join(kvPairs, ", ")
+		if remaining := width - lipgloss.Width(partsStr) - 3; remaining >= minSpaceForMainParam {
+			output = fmt.Sprintf("%s (%s)", mainParam, partsStr)
+		}
+	}
+
+	if width >= 0 {
+		output = ansi.Truncate(output, width, "…")
+	}
+	return sty.Tool.ParamMain.Render(output)
+}
+
+// toolHeader builds the tool header line: "● ToolName params..."
+func toolHeader(sty *styles.Styles, status ToolStatus, name string, width int, params ...string) string {
+	icon := toolIcon(sty, status)
+	toolName := sty.Tool.NameNested.Render(name)
+	prefix := fmt.Sprintf("%s %s ", icon, toolName)
+	prefixWidth := lipgloss.Width(prefix)
+	remainingWidth := width - prefixWidth
+	paramsStr := toolParamList(sty, params, remainingWidth)
+	return prefix + paramsStr
+}
+
+// toolOutputPlainContent renders plain text with optional expansion support.
+func toolOutputPlainContent(sty *styles.Styles, content string, width int, expanded bool) string {
+	content = strings.ReplaceAll(content, "\r\n", "\n")
+	content = strings.ReplaceAll(content, "\t", "    ")
+	content = strings.TrimSpace(content)
+	lines := strings.Split(content, "\n")
+
+	maxLines := responseContextHeight
+	if expanded {
+		maxLines = len(lines) // Show all
+	}
+
+	var out []string
+	for i, ln := range lines {
+		if i >= maxLines {
+			break
+		}
+		ln = " " + ln
+		if lipgloss.Width(ln) > width {
+			ln = ansi.Truncate(ln, width, "…")
+		}
+		out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
+	}
+
+	wasTruncated := len(lines) > responseContextHeight
+
+	if !expanded && wasTruncated {
+		out = append(out, sty.Tool.ContentTruncation.
+			Width(width).
+			Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
+	}
+
+	return strings.Join(out, "\n")
+}

internal/ui/model/chat.go 🔗

@@ -237,8 +237,16 @@ func (m *Chat) SelectLastInView() {
 	m.list.SelectLastInView()
 }
 
-// GetMessageItem returns the message item at the given id.
-func (m *Chat) GetMessageItem(id string) chat.MessageItem {
+// ClearMessages removes all messages from the chat list.
+func (m *Chat) ClearMessages() {
+	m.idInxMap = make(map[string]int)
+	m.pausedAnimations = make(map[string]struct{})
+	m.list.SetItems()
+	m.ClearMouse()
+}
+
+// MessageItem returns the message item with the given ID, or nil if not found.
+func (m *Chat) MessageItem(id string) chat.MessageItem {
 	idx, ok := m.idInxMap[id]
 	if !ok {
 		return nil

internal/ui/model/ui.go 🔗

@@ -208,6 +208,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		}
 
 	case pubsub.Event[message.Message]:
+		// TODO: handle nested messages for agentic tools
 		if m.session == nil || msg.Payload.SessionID != m.session.ID {
 			break
 		}
@@ -217,8 +218,6 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case pubsub.UpdatedEvent:
 			cmds = append(cmds, m.updateSessionMessage(msg.Payload))
 		}
-		// TODO: Finish implementing me
-		// cmds = append(cmds, m.setMessageEvents(msg.Payload))
 	case pubsub.Event[history.File]:
 		cmds = append(cmds, m.handleFileEvent(msg.Payload))
 	case pubsub.Event[app.LSPEvent]:
@@ -381,7 +380,7 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 	// Add messages to chat with linked tool results
 	items := make([]chat.MessageItem, 0, len(msgs)*2)
 	for _, msg := range msgPtrs {
-		items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...)
+		items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
 	}
 
 	// 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 {
 }
 
 // appendSessionMessage appends a new message to the current session in the chat
+// if the message is a tool result it will update the corresponding tool call message
 func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
-	items := chat.GetMessageItems(m.com.Styles, &msg, nil)
 	var cmds []tea.Cmd
+	switch msg.Role {
+	case message.User, message.Assistant:
+		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
+		for _, item := range items {
+			if animatable, ok := item.(chat.Animatable); ok {
+				if cmd := animatable.StartAnimation(); cmd != nil {
+					cmds = append(cmds, cmd)
+				}
+			}
+		}
+		m.chat.AppendMessages(items...)
+		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+			cmds = append(cmds, cmd)
+		}
+	case message.Tool:
+		for _, tr := range msg.ToolResults() {
+			toolItem := m.chat.MessageItem(tr.ToolCallID)
+			if toolItem == nil {
+				// we should have an item!
+				continue
+			}
+			if toolMsgItem, ok := toolItem.(chat.ToolMessageItem); ok {
+				toolMsgItem.SetResult(&tr)
+			}
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+// updateSessionMessage updates an existing message in the current session in the chat
+// when an assistant message is updated it may include updated tool calls as well
+// that is why we need to handle creating/updating each tool call message too
+func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	existingItem := m.chat.MessageItem(msg.ID)
+	if existingItem == nil || msg.Role != message.Assistant {
+		return nil
+	}
+
+	if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
+		assistantItem.SetMessage(&msg)
+	}
+
+	var items []chat.MessageItem
+	for _, tc := range msg.ToolCalls() {
+		existingToolItem := m.chat.MessageItem(tc.ID)
+		if toolItem, ok := existingToolItem.(chat.ToolMessageItem); ok {
+			existingToolCall := toolItem.ToolCall()
+			// only update if finished state changed or input changed
+			// to avoid clearing the cache
+			if (tc.Finished && !existingToolCall.Finished) || tc.Input != existingToolCall.Input {
+				toolItem.SetToolCall(tc)
+			}
+		}
+		if existingToolItem == nil {
+			items = append(items, chat.NewToolMessageItem(m.com.Styles, tc, nil, false))
+		}
+	}
+
 	for _, item := range items {
 		if animatable, ok := item.(chat.Animatable); ok {
 			if cmd := animatable.StartAnimation(); cmd != nil {
@@ -417,21 +475,8 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 	if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 		cmds = append(cmds, cmd)
 	}
-	return tea.Batch(cmds...)
-}
 
-// updateSessionMessage updates an existing message in the current session in the chat
-// INFO: currently only updates the assistant when I add tools this will get a bit more complex
-func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
-	existingItem := m.chat.GetMessageItem(msg.ID)
-	switch msg.Role {
-	case message.Assistant:
-		if assistantItem, ok := existingItem.(*chat.AssistantMessageItem); ok {
-			assistantItem.SetMessage(&msg)
-		}
-	}
-
-	return nil
+	return tea.Batch(cmds...)
 }
 
 func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
@@ -498,6 +543,13 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 		case dialog.SwitchSessionsMsg:
 			cmds = append(cmds, m.listSessions)
 			m.dialog.CloseDialog(dialog.CommandsID)
+		case dialog.NewSessionsMsg:
+			if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+				cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+				break
+			}
+			m.newSession()
+			m.dialog.CloseDialog(dialog.CommandsID)
 		case dialog.CompactMsg:
 			err := m.com.App.AgentCoordinator.Summarize(context.Background(), msg.SessionID)
 			if err != nil {
@@ -548,6 +600,15 @@ func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
 				m.randomizePlaceholders()
 
 				return m.sendMessage(value, attachments)
+			case key.Matches(msg, m.keyMap.Chat.NewSession):
+				if m.session == nil || m.session.ID == "" {
+					break
+				}
+				if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() {
+					cmds = append(cmds, uiutil.ReportWarn("Agent is busy, please wait before starting a new session..."))
+					break
+				}
+				m.newSession()
 			case key.Matches(msg, m.keyMap.Tab):
 				m.focus = uiFocusMain
 				m.textarea.Blur()
@@ -1362,6 +1423,22 @@ func (m *UI) listSessions() tea.Msg {
 	return listSessionsMsg{sessions: allSessions}
 }
 
+// newSession clears the current session state and prepares for a new session.
+// The actual session creation happens when the user sends their first message.
+func (m *UI) newSession() {
+	if m.session == nil || m.session.ID == "" {
+		return
+	}
+
+	m.session = nil
+	m.sessionFiles = nil
+	m.state = uiLanding
+	m.focus = uiFocusEditor
+	m.textarea.Focus()
+	m.chat.Blur()
+	m.chat.ClearMessages()
+}
+
 // handlePasteMsg handles a paste message.
 func (m *UI) handlePasteMsg(msg tea.PasteMsg) tea.Cmd {
 	if m.focus != uiFocusEditor {

internal/ui/styles/styles.go 🔗

@@ -26,6 +26,8 @@ const (
 	DocumentIcon string = "🖼"
 	ModelIcon    string = "◇"
 
+	ArrowRightIcon string = "→"
+
 	ToolPending string = "●"
 	ToolSuccess string = "✓"
 	ToolError   string = "×"
@@ -34,6 +36,10 @@ const (
 	BorderThick string = "▌"
 
 	SectionSeparator string = "─"
+
+	TodoCompletedIcon  string = "✓"
+	TodoPendingIcon    string = "•"
+	TodoInProgressIcon string = "→"
 )
 
 const (
@@ -227,7 +233,7 @@ type Styles struct {
 		ContentTruncation lipgloss.Style // Truncation message "… (N lines)"
 		ContentCodeLine   lipgloss.Style // Code line with background and width
 		ContentCodeBg     color.Color    // Background color for syntax highlighting
-		BodyPadding       lipgloss.Style // Body content padding (PaddingLeft(2))
+		Body              lipgloss.Style // Body content padding (PaddingLeft(2))
 
 		// Deprecated - kept for backward compatibility
 		ContentBg         lipgloss.Style // Content background
@@ -956,7 +962,7 @@ func DefaultStyles() Styles {
 	s.Tool.ContentTruncation = s.Muted.Background(bgBaseLighter)
 	s.Tool.ContentCodeLine = s.Base.Background(bgBaseLighter)
 	s.Tool.ContentCodeBg = bgBase
-	s.Tool.BodyPadding = base.PaddingLeft(2)
+	s.Tool.Body = base.PaddingLeft(2)
 
 	// Deprecated - kept for backward compatibility
 	s.Tool.ContentBg = s.Muted.Background(bgBaseLighter)