diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md index 6a5ce8eb2a6104e6462d57dd52e47399bc9cc773..7fce65ce12d69d2d1be0268c9acbd45fd7605851 100644 --- a/internal/ui/AGENTS.md +++ b/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 diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index d2e655d57ab7549411a1fa2d23f5beb52d4f92cd..68954b0f3f0168b9da91b1b28db1b5101e5f9c3b 100644 --- a/internal/ui/chat/messages.go +++ b/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) diff --git a/internal/ui/common/elements.go b/internal/ui/common/elements.go index f2256676d47b5f5b706ca05e7e4d5a21d6d5867a..ccb7f7cdb2677980ddac4a55e153354c9f220962 100644 --- a/internal/ui/common/elements.go +++ b/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 != "" { diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index 76a82a7d7242b4b089381685763750ee762c043c..a6c8fb1cf213be37c8f095ba776f936bec96b57a 100644 --- a/internal/ui/model/chat.go +++ b/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 diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index c0e46eb31530bc9b9d4f62fbfb020afdd7abc009..2437fab9177b9186cfcd4c185c45c48204cea7d9 100644 --- a/internal/ui/model/sidebar.go +++ b/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 diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 57253bd968b097e00e15b7c5abfe324ad64d72f4..46f4b658f7509c899dfa7f35f164f34f27a1ea43 100644 --- a/internal/ui/model/ui.go +++ b/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 diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 97711efaa7b951f13aa01a0a823ee7514919b136..42cf3c8dbd44f8983f588bc303ef7ae142e71a70 100644 --- a/internal/ui/styles/styles.go +++ b/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)