Detailed changes
@@ -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
@@ -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)
@@ -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 != "" {
@@ -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
@@ -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
@@ -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
@@ -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)