refactor: add assistant info item (#1881)

Kujtim Hoxha created

Change summary

internal/ui/AGENTS.md          |  2 
internal/ui/chat/messages.go   | 85 +++++++++++++++++++++++++++++++
internal/ui/common/elements.go | 32 +++++++++--
internal/ui/model/chat.go      | 97 +++++++++++++++++++++++++++++++++--
internal/ui/model/sidebar.go   | 33 +++++++----
internal/ui/model/ui.go        | 58 ++++++++++++++++++++-
internal/ui/styles/styles.go   |  8 ++
7 files changed, 287 insertions(+), 28 deletions(-)

Detailed changes

internal/ui/AGENTS.md 🔗

@@ -4,6 +4,8 @@
 - Never use commands to send messages when you can directly mutate children or state.
 - Keep things simple; do not overcomplicate.
 - Create files if needed to separate logic; do not nest models.
+- Always do IO in commands
+- Never change the model state inside of a command use messages and than update the state in the main loop
 
 ## Architecture
 

internal/ui/chat/messages.go 🔗

@@ -4,13 +4,19 @@
 package chat
 
 import (
+	"fmt"
 	"image"
 	"strings"
+	"time"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
+	"github.com/charmbracelet/catwalk/pkg/catwalk"
+	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/message"
 	"github.com/charmbracelet/crush/internal/ui/anim"
 	"github.com/charmbracelet/crush/internal/ui/attachments"
+	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 )
@@ -42,9 +48,19 @@ type Expandable interface {
 // UI and be part of a [list.List] identifiable by a unique ID.
 type MessageItem interface {
 	list.Item
+	Identifiable
+}
+
+// HighlightableMessageItem is a message item that supports highlighting.
+type HighlightableMessageItem interface {
+	MessageItem
 	list.Highlightable
+}
+
+// FocusableMessageItem is a message item that supports focus.
+type FocusableMessageItem interface {
+	MessageItem
 	list.Focusable
-	Identifiable
 }
 
 // SendMsg represents a message to send a chat message.
@@ -146,6 +162,73 @@ func (f *focusableMessageItem) SetFocused(focused bool) {
 	f.focused = focused
 }
 
+// AssistantInfoID returns a stable ID for assistant info items.
+func AssistantInfoID(messageID string) string {
+	return fmt.Sprintf("%s:assistant-info", messageID)
+}
+
+// AssistantInfoItem renders model info and response time after assistant completes.
+type AssistantInfoItem struct {
+	*cachedMessageItem
+
+	id                  string
+	message             *message.Message
+	sty                 *styles.Styles
+	lastUserMessageTime time.Time
+}
+
+// NewAssistantInfoItem creates a new AssistantInfoItem.
+func NewAssistantInfoItem(sty *styles.Styles, message *message.Message, lastUserMessageTime time.Time) MessageItem {
+	return &AssistantInfoItem{
+		cachedMessageItem:   &cachedMessageItem{},
+		id:                  AssistantInfoID(message.ID),
+		message:             message,
+		sty:                 sty,
+		lastUserMessageTime: lastUserMessageTime,
+	}
+}
+
+// ID implements MessageItem.
+func (a *AssistantInfoItem) ID() string {
+	return a.id
+}
+
+// Render implements MessageItem.
+func (a *AssistantInfoItem) Render(width int) string {
+	innerWidth := max(0, width-messageLeftPaddingTotal)
+	content, _, ok := a.getCachedRender(innerWidth)
+	if !ok {
+		content = a.renderContent(innerWidth)
+		height := lipgloss.Height(content)
+		a.setCachedRender(content, innerWidth, height)
+	}
+
+	return a.sty.Chat.Message.SectionHeader.Render(content)
+}
+
+func (a *AssistantInfoItem) renderContent(width int) string {
+	finishData := a.message.FinishPart()
+	if finishData == nil {
+		return ""
+	}
+	finishTime := time.Unix(finishData.Time, 0)
+	duration := finishTime.Sub(a.lastUserMessageTime)
+	infoMsg := a.sty.Chat.Message.AssistantInfoDuration.Render(duration.String())
+	icon := a.sty.Chat.Message.AssistantInfoIcon.Render(styles.ModelIcon)
+	model := config.Get().GetModel(a.message.Provider, a.message.Model)
+	if model == nil {
+		model = &catwalk.Model{Name: "Unknown Model"}
+	}
+	modelFormatted := a.sty.Chat.Message.AssistantInfoModel.Render(model.Name)
+	providerName := a.message.Provider
+	if providerConfig, ok := config.Get().Providers.Get(a.message.Provider); ok {
+		providerName = providerConfig.Name
+	}
+	provider := a.sty.Chat.Message.AssistantInfoProvider.Render(fmt.Sprintf("via %s", providerName))
+	assistant := fmt.Sprintf("%s %s %s %s", icon, modelFormatted, provider, infoMsg)
+	return common.Section(a.sty, assistant, width)
+}
+
 // cappedMessageWidth returns the maximum width for message content for readability.
 func cappedMessageWidth(availableWidth int) int {
 	return min(availableWidth-messageLeftPaddingTotal, maxTextWidth)

internal/ui/common/elements.go 🔗

@@ -26,15 +26,35 @@ type ModelContextInfo struct {
 	Cost         float64
 }
 
-// ModelInfo renders model information including name, reasoning settings, and
-// optional context usage/cost.
-func ModelInfo(t *styles.Styles, modelName string, reasoningInfo string, context *ModelContextInfo, width int) string {
+// ModelInfo renders model information including name, provider, reasoning
+// settings, and optional context usage/cost.
+func ModelInfo(t *styles.Styles, modelName, providerName, reasoningInfo string, context *ModelContextInfo, width int) string {
 	modelIcon := t.Subtle.Render(styles.ModelIcon)
 	modelName = t.Base.Render(modelName)
-	modelInfo := fmt.Sprintf("%s %s", modelIcon, modelName)
 
-	parts := []string{
-		modelInfo,
+	// Build first line with model name and optionally provider on the same line
+	var firstLine string
+	if providerName != "" {
+		providerInfo := t.Muted.Render(fmt.Sprintf("via %s", providerName))
+		modelWithProvider := fmt.Sprintf("%s %s %s", modelIcon, modelName, providerInfo)
+
+		// Check if it fits on one line
+		if lipgloss.Width(modelWithProvider) <= width {
+			firstLine = modelWithProvider
+		} else {
+			// If it doesn't fit, put provider on next line
+			firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+		}
+	} else {
+		firstLine = fmt.Sprintf("%s %s", modelIcon, modelName)
+	}
+
+	parts := []string{firstLine}
+
+	// If provider didn't fit on first line, add it as second line
+	if providerName != "" && !strings.Contains(firstLine, "via") {
+		providerInfo := fmt.Sprintf("via %s", providerName)
+		parts = append(parts, t.Muted.PaddingLeft(2).Render(providerInfo))
 	}
 
 	if reasoningInfo != "" {

internal/ui/model/chat.go 🔗

@@ -238,39 +238,121 @@ func (m *Chat) SelectedItemInView() bool {
 	return m.list.SelectedItemInView()
 }
 
+func (m *Chat) isSelectable(index int) bool {
+	item := m.list.ItemAt(index)
+	if item == nil {
+		return false
+	}
+	_, ok := item.(list.Focusable)
+	return ok
+}
+
 // SetSelected sets the selected message index in the chat list.
 func (m *Chat) SetSelected(index int) {
 	m.list.SetSelected(index)
+	if index < 0 || index >= m.list.Len() {
+		return
+	}
+	for {
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+		if m.list.SelectNext() {
+			continue
+		}
+		// If we're at the end and the last item isn't selectable, walk backwards
+		// to find the nearest selectable item.
+		for {
+			if !m.list.SelectPrev() {
+				return
+			}
+			if m.isSelectable(m.list.Selected()) {
+				return
+			}
+		}
+	}
 }
 
 // SelectPrev selects the previous message in the chat list.
 func (m *Chat) SelectPrev() {
-	m.list.SelectPrev()
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
 }
 
 // SelectNext selects the next message in the chat list.
 func (m *Chat) SelectNext() {
-	m.list.SelectNext()
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
 }
 
 // SelectFirst selects the first message in the chat list.
 func (m *Chat) SelectFirst() {
-	m.list.SelectFirst()
+	if !m.list.SelectFirst() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectNext() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
 }
 
 // SelectLast selects the last message in the chat list.
 func (m *Chat) SelectLast() {
-	m.list.SelectLast()
+	if !m.list.SelectLast() {
+		return
+	}
+	if m.isSelectable(m.list.Selected()) {
+		return
+	}
+	for {
+		if !m.list.SelectPrev() {
+			return
+		}
+		if m.isSelectable(m.list.Selected()) {
+			return
+		}
+	}
 }
 
 // SelectFirstInView selects the first message currently in view.
 func (m *Chat) SelectFirstInView() {
-	m.list.SelectFirstInView()
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := startIdx; i <= endIdx; i++ {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
 }
 
 // SelectLastInView selects the last message currently in view.
 func (m *Chat) SelectLastInView() {
-	m.list.SelectLastInView()
+	startIdx, endIdx := m.list.VisibleItemIndices()
+	for i := endIdx; i >= startIdx; i-- {
+		if m.isSelectable(i) {
+			m.list.SetSelected(i)
+			return
+		}
+	}
 }
 
 // ClearMessages removes all messages from the chat list.
@@ -335,6 +417,9 @@ func (m *Chat) HandleMouseDown(x, y int) bool {
 	if itemIdx < 0 {
 		return false
 	}
+	if !m.isSelectable(itemIdx) {
+		return false
+	}
 
 	m.mouseDown = true
 	m.mouseDownItem = itemIdx

internal/ui/model/sidebar.go 🔗

@@ -18,23 +18,32 @@ import (
 func (m *UI) modelInfo(width int) string {
 	model := m.selectedLargeModel()
 	reasoningInfo := ""
-	if model != nil && model.CatwalkCfg.CanReason {
+	providerName := ""
+
+	if model != nil {
+		// Get provider name first
 		providerConfig, ok := m.com.Config().Providers.Get(model.ModelCfg.Provider)
 		if ok {
-			switch providerConfig.Type {
-			case catwalk.TypeAnthropic:
-				if model.ModelCfg.Think {
-					reasoningInfo = "Thinking On"
-				} else {
-					reasoningInfo = "Thinking Off"
+			providerName = providerConfig.Name
+
+			// Only check reasoning if model can reason
+			if model.CatwalkCfg.CanReason {
+				switch providerConfig.Type {
+				case catwalk.TypeAnthropic:
+					if model.ModelCfg.Think {
+						reasoningInfo = "Thinking On"
+					} else {
+						reasoningInfo = "Thinking Off"
+					}
+				default:
+					formatter := cases.Title(language.English, cases.NoLower)
+					reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
+					reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
 				}
-			default:
-				formatter := cases.Title(language.English, cases.NoLower)
-				reasoningEffort := cmp.Or(model.ModelCfg.ReasoningEffort, model.CatwalkCfg.DefaultReasoningEffort)
-				reasoningInfo = formatter.String(fmt.Sprintf("Reasoning %s", reasoningEffort))
 			}
 		}
 	}
+
 	var modelContext *common.ModelContextInfo
 	if m.session != nil {
 		modelContext = &common.ModelContextInfo{
@@ -43,7 +52,7 @@ func (m *UI) modelInfo(width int) string {
 			ModelContext: model.CatwalkCfg.ContextWindow,
 		}
 	}
-	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, reasoningInfo, modelContext, width)
+	return common.ModelInfo(m.com.Styles, model.CatwalkCfg.Name, providerName, reasoningInfo, modelContext, width)
 }
 
 // getDynamicHeightLimits will give us the num of items to show in each section based on the hight

internal/ui/model/ui.go 🔗

@@ -110,6 +110,8 @@ type UI struct {
 	session      *session.Session
 	sessionFiles []SessionFile
 
+	lastUserMessageTime int64
+
 	// The width and height of the terminal in cells.
 	width  int
 	height int
@@ -596,11 +598,26 @@ func (m *UI) setSessionMessages(msgs []message.Message) tea.Cmd {
 		msgPtrs[i] = &msgs[i]
 	}
 	toolResultMap := chat.BuildToolResultMap(msgPtrs)
+	if len(msgPtrs) > 0 {
+		m.lastUserMessageTime = msgPtrs[0].CreatedAt
+	}
 
 	// Add messages to chat with linked tool results
 	items := make([]chat.MessageItem, 0, len(msgs)*2)
 	for _, msg := range msgPtrs {
-		items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		switch msg.Role {
+		case message.User:
+			m.lastUserMessageTime = msg.CreatedAt
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		case message.Assistant:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+			if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+				infoItem := chat.NewAssistantInfoItem(m.com.Styles, msg, time.Unix(m.lastUserMessageTime, 0))
+				items = append(items, infoItem)
+			}
+		default:
+			items = append(items, chat.ExtractMessageItems(m.com.Styles, msg, toolResultMap)...)
+		}
 	}
 
 	// Load nested tool calls for agent/agentic_fetch tools.
@@ -692,7 +709,21 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 		return nil
 	}
 	switch msg.Role {
-	case message.User, message.Assistant:
+	case message.User:
+		m.lastUserMessageTime = msg.CreatedAt
+		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.Assistant:
 		items := chat.ExtractMessageItems(m.com.Styles, &msg, nil)
 		for _, item := range items {
 			if animatable, ok := item.(chat.Animatable); ok {
@@ -705,6 +736,13 @@ func (m *UI) appendSessionMessage(msg message.Message) tea.Cmd {
 		if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
 			cmds = append(cmds, cmd)
 		}
+		if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+			infoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			m.chat.AppendMessages(infoItem)
+			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)
@@ -733,9 +771,23 @@ func (m *UI) updateSessionMessage(msg message.Message) tea.Cmd {
 		}
 	}
 
+	shouldRenderAssistant := chat.ShouldRenderAssistantMessage(&msg)
 	// if the message of the assistant does not have any  response just tool calls we need to remove it
-	if !chat.ShouldRenderAssistantMessage(&msg) && len(msg.ToolCalls()) > 0 && existingItem != nil {
+	if !shouldRenderAssistant && len(msg.ToolCalls()) > 0 && existingItem != nil {
 		m.chat.RemoveMessage(msg.ID)
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem != nil {
+			m.chat.RemoveMessage(chat.AssistantInfoID(msg.ID))
+		}
+	}
+
+	if shouldRenderAssistant && msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
+		if infoItem := m.chat.MessageItem(chat.AssistantInfoID(msg.ID)); infoItem == nil {
+			newInfoItem := chat.NewAssistantInfoItem(m.com.Styles, &msg, time.Unix(m.lastUserMessageTime, 0))
+			m.chat.AppendMessages(newInfoItem)
+			if cmd := m.chat.ScrollToBottomAndAnimate(); cmd != nil {
+				cmds = append(cmds, cmd)
+			}
+		}
 	}
 
 	var items []chat.MessageItem

internal/ui/styles/styles.go 🔗

@@ -238,6 +238,10 @@ type Styles struct {
 			ThinkingTruncationHint lipgloss.Style // "… (N lines hidden)" hint
 			ThinkingFooterTitle    lipgloss.Style // "Thought for" text
 			ThinkingFooterDuration lipgloss.Style // Duration value
+			AssistantInfoIcon      lipgloss.Style
+			AssistantInfoModel     lipgloss.Style
+			AssistantInfoProvider  lipgloss.Style
+			AssistantInfoDuration  lipgloss.Style
 		}
 	}
 
@@ -1193,6 +1197,10 @@ func DefaultStyles() Styles {
 	// No padding or border for compact tool calls within messages
 	s.Chat.Message.ToolCallCompact = s.Muted
 	s.Chat.Message.SectionHeader = s.Base.PaddingLeft(2)
+	s.Chat.Message.AssistantInfoIcon = s.Subtle
+	s.Chat.Message.AssistantInfoModel = s.Muted
+	s.Chat.Message.AssistantInfoProvider = s.Subtle
+	s.Chat.Message.AssistantInfoDuration = s.Subtle
 
 	// Thinking section styles
 	s.Chat.Message.ThinkingBox = s.Subtle.Background(bgBaseLighter)