From 97147f0bc45037f6ebc14e6d74567d5e0fe3f4ed Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Fri, 20 Jun 2025 10:54:17 +0200 Subject: [PATCH] chore: add assistant section --- internal/tui/components/chat/chat.go | 17 ++- .../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(-) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 332d0ff5070290377342e75b0af6c2f4a59d70e5..95c9ad2d2831ab39da9ddd524fec2932ad9ddc73 100644 --- a/internal/tui/components/chat/chat.go +++ b/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)), ), ) } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 51901308b6a20b65bbaa0779d8e3340675d2b1e5..b8bb27dac8eb5b9ec3824189741f3443db1f2c7a 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 65274b11c489e4e78ef0e70fe7a3adbe1f82806d..d8e7500391a9868f7941683137227d54e58d88b9 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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. diff --git a/todos.md b/todos.md index ca0ad74ef08258b6b209a3da7ff79f3922ae9e40..080bf64df8dd6e4d5a496531ba5f8f2be5fcf8a4 100644 --- a/todos.md +++ b/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