chore: add assistant section

Kujtim Hoxha created

Change summary

internal/tui/components/chat/chat.go              |  17 ++
internal/tui/components/chat/messages/messages.go | 106 ++++++++++------
internal/tui/components/chat/messages/tool.go     |  11 -
todos.md                                          |   9 +
4 files changed, 89 insertions(+), 54 deletions(-)

Detailed changes

internal/tui/components/chat/chat.go 🔗

@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
 				assistantIndex = i
 			}
 		} else if tc, ok := item.(messages.ToolCallCmp); ok {
-			if tc.ParentMessageId() == messageID {
+			if tc.ParentMessageID() == messageID {
 				toolCalls[i] = tc
 			}
 		}
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
 			assistantIndex,
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
+
+		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+			m.listCmp.AppendItem(
+				messages.NewAssistantSection(
+					msg,
+					time.Unix(m.lastUserMessageTime, 0),
+				),
+			)
+		}
 	} else if hasToolCallsOnly {
 		m.listCmp.DeleteItem(assistantIndex)
 	}
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
 		cmd := m.listCmp.AppendItem(
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
 		cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
 			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
 		case message.Assistant:
 			uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+				uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
+			}
 		}
 	}
 
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
 			uiMessages,
 			messages.NewMessageCmp(
 				msg,
-				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
 			),
 		)
 	}

internal/tui/components/chat/messages/messages.go 🔗

@@ -8,13 +8,14 @@ import (
 
 	"github.com/charmbracelet/bubbles/v2/spinner"
 	tea "github.com/charmbracelet/bubbletea/v2"
-	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/lipgloss/v2"
 
+	"github.com/charmbracelet/crush/internal/llm/models"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/tui/components/anim"
 	"github.com/charmbracelet/crush/internal/tui/components/core"
 	"github.com/charmbracelet/crush/internal/tui/components/core/layout"
+	"github.com/charmbracelet/crush/internal/tui/components/core/list"
 	"github.com/charmbracelet/crush/internal/tui/styles"
 	"github.com/charmbracelet/crush/internal/tui/util"
 )
@@ -37,32 +38,17 @@ type messageCmp struct {
 	focused bool // Focus state for border styling
 
 	// Core message data and state
-	message             message.Message // The underlying message content
-	spinning            bool            // Whether to show loading animation
-	anim                util.Model      // Animation component for loading states
-	lastUserMessageTime time.Time       // Used for calculating response duration
-}
-
-// MessageOption provides functional options for configuring message components
-type MessageOption func(*messageCmp)
-
-// WithLastUserMessageTime sets the timestamp of the last user message
-// for calculating assistant response duration
-func WithLastUserMessageTime(t time.Time) MessageOption {
-	return func(m *messageCmp) {
-		m.lastUserMessageTime = t
-	}
+	message  message.Message // The underlying message content
+	spinning bool            // Whether to show loading animation
+	anim     util.Model      // Animation component for loading states
 }
 
 // NewMessageCmp creates a new message component with the given message and options
-func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
+func NewMessageCmp(msg message.Message) MessageCmp {
 	m := &messageCmp{
 		message: msg,
 		anim:    anim.New(15, ""),
 	}
-	for _, opt := range opts {
-		opt(m)
-	}
 	return m
 }
 
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
 // renderAssistantMessage renders assistant messages with optional footer information.
 // Shows model name, response time, and finish reason when the message is complete.
 func (m *messageCmp) renderAssistantMessage() string {
-	t := styles.CurrentTheme()
 	parts := []string{
 		m.markdownContent(),
 	}
 
-	finished := m.message.IsFinished()
-	finishData := m.message.FinishPart()
-	// Only show the footer if the message is not a tool call
-	if finished && finishData.Reason != message.FinishReasonToolUse {
-		infoMsg := ""
-		switch finishData.Reason {
-		case message.FinishReasonEndTurn:
-			finishTime := time.Unix(finishData.Time, 0)
-			duration := finishTime.Sub(m.lastUserMessageTime)
-			infoMsg = duration.String()
-		case message.FinishReasonCanceled:
-			infoMsg = "canceled"
-		case message.FinishReasonError:
-			infoMsg = "error"
-		case message.FinishReasonPermissionDenied:
-			infoMsg = "permission denied"
-		}
-		assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
-		parts = append(parts, core.Section(assistant, m.textWidth()))
-	}
-
 	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
 	return m.style().Render(joined)
 }
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
 		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
 			// Sometimes the LLMs respond with no content when they think the previous tool result
 			//  provides the requested question
-			content = "*Finished without output*"
+			content = ""
 		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
 			content = "*Canceled*"
 		}
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
 func (m *messageCmp) Spinning() bool {
 	return m.spinning
 }
+
+type AssistantSection interface {
+	util.Model
+	layout.Sizeable
+	list.SectionHeader
+}
+type assistantSectionModel struct {
+	width               int
+	message             message.Message
+	lastUserMessageTime time.Time
+}
+
+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
+	return &assistantSectionModel{
+		width:               0,
+		message:             message,
+		lastUserMessageTime: lastUserMessageTime,
+	}
+}
+
+func (m *assistantSectionModel) Init() tea.Cmd {
+	return nil
+}
+
+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *assistantSectionModel) View() tea.View {
+	t := styles.CurrentTheme()
+	finishData := m.message.FinishPart()
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(m.lastUserMessageTime)
+	infoMsg := t.S().Subtle.Render(duration.String())
+	icon := t.S().Subtle.Render(styles.ModelIcon)
+	model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
+	assistant := fmt.Sprintf("%s  %s %s", icon, model, infoMsg)
+	return tea.NewView(
+		t.S().Base.PaddingLeft(2).Render(
+			core.Section(assistant, m.width-1),
+		),
+	)
+}
+
+func (m *assistantSectionModel) GetSize() (int, int) {
+	return m.width, 1
+}
+
+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}
+
+func (m *assistantSectionModel) IsSectionHeader() bool {
+	return true
+}

internal/tui/components/chat/messages/tool.go 🔗

@@ -25,7 +25,7 @@ type ToolCallCmp interface {
 	SetToolResult(message.ToolResult)  // Update tool result
 	SetToolCall(message.ToolCall)      // Update tool call
 	SetCancelled()                     // Mark as cancelled
-	ParentMessageId() string           // Get parent message ID
+	ParentMessageID() string           // Get parent message ID
 	Spinning() bool                    // Animation state for pending tools
 	GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
 	SetNestedToolCalls([]ToolCallCmp)  // Set nested tool calls
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
 	box := m.style()
 
 	if !m.call.Finished && !m.cancelled {
-		if m.isNested {
-			return tea.NewView(box.Render(m.renderPending()))
-		}
 		return tea.NewView(box.Render(m.renderPending()))
 	}
 
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
 	}
 }
 
-// ParentMessageId returns the ID of the message that initiated this tool call
-func (m *toolCallCmp) ParentMessageId() string {
+// ParentMessageID returns the ID of the message that initiated this tool call
+func (m *toolCallCmp) ParentMessageID() string {
 	return m.parentMessageID
 }
 
@@ -212,7 +209,7 @@ func (m *toolCallCmp) renderPending() string {
 	t := styles.CurrentTheme()
 	icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
 	tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
-	return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
+	return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
 }
 
 // style returns the lipgloss style for the tool call component.

todos.md 🔗

@@ -20,7 +20,16 @@
 - [ ] Parallel tool calls and permissions
   - [ ] Run the tools in parallel and add results in parallel
   - [ ] Show multiple permissions dialogs
+- [ ] Add another space around buttons
+- [ ] Completions
+  - [ ] Should change the help to show the completions stuff
+  - [ ] Should make it wider
+  - [ ] Tab and ctrl+y should accept
+  - [ ] Words should line up
+  - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
 - [ ] Investigate messages issues
+  - [ ] Make the agent separator look like the
+  - [ ] Cleanup tool calls (watch all states)
   - [ ] Weird behavior sometimes the message does not update
   - [ ] Message length (I saw the message go beyond the correct length when there are errors)
   - [ ] Address UX issues