diff --git a/CRUSH.md b/AGENTS.md similarity index 96% rename from CRUSH.md rename to AGENTS.md index b98b8813fde6109bd5fdbbc21b1c2f92dee602af..7fab72afb836136020500b7f27e905f3dcfc72da 100644 --- a/CRUSH.md +++ b/AGENTS.md @@ -70,3 +70,6 @@ func TestYourFunction(t *testing.T) { - ALWAYS use semantic commits (`fix:`, `feat:`, `chore:`, `refactor:`, `docs:`, `sec:`, etc). - Try to keep commits to one line, not including your attribution. Only use multi-line commits when additional context is truly necessary. + +## Working on the TUI (UI) +Anytime you need to work on the tui before starting work read the internal/ui/AGENTS.md file diff --git a/cspell.json b/cspell.json index 713684deb4cf3f066d92b6a71a063df90cddf0fc..c0faed860420b452c9f22592a27a327b8895f2fc 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"flagWords":[],"version":"0.2","language":"en","words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync"]} \ No newline at end of file +{"version":"0.2","language":"en","flagWords":[],"words":["afero","agentic","alecthomas","anthropics","aymanbagabas","azidentity","bmatcuk","bubbletea","charlievieth","charmbracelet","charmtone","Charple","chkconfig","crush","curlie","cursorrules","diffview","doas","Dockerfiles","doublestar","dpkg","Emph","fastwalk","fdisk","filepicker","Focusable","fseventsd","fsext","genai","goquery","GROQ","Guac","imageorient","Inex","jetta","jsons","jsonschema","jspm","Kaufmann","killall","Lanczos","lipgloss","LOCALAPPDATA","lsps","lucasb","makepkg","mcps","MSYS","mvdan","natefinch","nfnt","noctx","nohup","nolint","nslookup","oksvg","Oneshot","openrouter","opkg","pacman","paru","pfctl","postamble","postambles","preconfigured","Preproc","Proactiveness","Puerkito","pycache","pytest","qjebbs","rasterx","rivo","sabhiram","sess","shortlog","sjson","Sourcegraph","srwiley","SSEMCP","Streamable","stretchr","Strikethrough","substrs","Suscriber","systeminfo","tasklist","termenv","textinput","tidwall","timedout","trashhalo","udiff","uniseg","Unticked","urllib","USERPROFILE","VERTEXAI","webp","whatis","whereis","sahilm","csync","Highlightable","Highlightable","prerendered","prerender","kujtim"]} \ No newline at end of file diff --git a/internal/ui/AGENTS.md b/internal/ui/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fef22d3df835f38efb2265c0021ff1beefed5714 --- /dev/null +++ b/internal/ui/AGENTS.md @@ -0,0 +1,17 @@ +# UI Development Instructions + +## General guideline +- Never use commands to send messages when you can directly mutate children or state +- Keep things simple do not overcomplicated +- Create files if needed to separate logic do not nest models + +## Big model +Keep most of the logic and state in the main model `internal/ui/model/ui.go`. + + +## When working on components +Whenever you work on components make them dumb they should not handle bubble tea messages they should have methods. + +## When adding logic that has to do with the chat +Most of the logic with the chat should be in the chat component `internal/ui/model/chat.go`, keep individual items dumb and handle logic in this component. + diff --git a/internal/ui/chat/messages.go b/internal/ui/chat/messages.go index 1bae62e2b2301bdabbbdb68828705f1360ccd49f..5fd52ff854e15fc7170bb58175196ad3aeb47306 100644 --- a/internal/ui/chat/messages.go +++ b/internal/ui/chat/messages.go @@ -1,9 +1,147 @@ package chat -import "github.com/charmbracelet/crush/internal/message" +import ( + "image" + + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/list" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// this is the total width that is taken up by the border + padding +// we also cap the width so text is readable to the maxTextWidth(120) +const messageLeftPaddingTotal = 2 + +// maxTextWidth is the maximum width text messages can be +const maxTextWidth = 120 + +// Identifiable is an interface for items that can provide a unique identifier. +type Identifiable interface { + ID() string +} + +// MessageItem represents a [message.Message] item that can be displayed in the +// UI and be part of a [list.List] identifiable by a unique ID. +type MessageItem interface { + list.Item + list.Highlightable + list.Focusable + Identifiable +} // SendMsg represents a message to send a chat message. type SendMsg struct { Text string Attachments []message.Attachment } + +type highlightableMessageItem struct { + startLine int + startCol int + endLine int + endCol int + highlighter list.Highlighter +} + +// isHighlighted returns true if the item has a highlight range set. +func (h *highlightableMessageItem) isHighlighted() bool { + return h.startLine != -1 || h.endLine != -1 +} + +// renderHighlighted highlights the content if necessary. +func (h *highlightableMessageItem) renderHighlighted(content string, width, height int) string { + if !h.isHighlighted() { + return content + } + area := image.Rect(0, 0, width, height) + return list.Highlight(content, area, h.startLine, h.startCol, h.endLine, h.endCol, h.highlighter) +} + +// Highlight implements MessageItem. +func (h *highlightableMessageItem) Highlight(startLine int, startCol int, endLine int, endCol int) { + // Adjust columns for the style's left inset (border + padding) since we + // highlight the content only. + offset := messageLeftPaddingTotal + h.startLine = startLine + h.startCol = max(0, startCol-offset) + h.endLine = endLine + if endCol >= 0 { + h.endCol = max(0, endCol-offset) + } else { + h.endCol = endCol + } +} + +func defaultHighlighter(sty *styles.Styles) *highlightableMessageItem { + return &highlightableMessageItem{ + startLine: -1, + startCol: -1, + endLine: -1, + endCol: -1, + highlighter: list.ToHighlighter(sty.TextSelection), + } +} + +// cachedMessageItem caches rendered message content to avoid re-rendering. +// +// This should be used by any message that can store a cahced version of its render. e.x user,assistant... and so on +// +// THOUGHT(kujtim): we should consider if its efficient to store the render for different widths +// the issue with that could be memory usage +type cachedMessageItem struct { + // rendered is the cached rendered string + rendered string + // width and height are the dimensions of the cached render + width int + height int +} + +// getCachedRender returns the cached render if it exists for the given width. +func (c *cachedMessageItem) getCachedRender(width int) (string, int, bool) { + if c.width == width && c.rendered != "" { + return c.rendered, c.height, true + } + return "", 0, false +} + +// setCachedRender sets the cached render. +func (c *cachedMessageItem) setCachedRender(rendered string, width, height int) { + c.rendered = rendered + c.width = width + c.height = height +} + +// cappedMessageWidth returns the maximum width for message content for readability. +func cappedMessageWidth(availableWidth int) int { + return min(availableWidth-messageLeftPaddingTotal, maxTextWidth) +} + +// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns +// all parts of the message as [MessageItem]s. +// +// For assistant messages with tool calls, pass a toolResults map to link results. +// Use BuildToolResultMap to create this map from all messages in a session. +func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { + switch msg.Role { + case message.User: + return []MessageItem{NewUserMessageItem(sty, msg)} + } + return []MessageItem{} +} + +// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. +// Tool result messages (role == message.Tool) contain the results that should be linked +// to tool calls in assistant messages. +func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { + resultMap := make(map[string]message.ToolResult) + for _, msg := range messages { + if msg.Role == message.Tool { + for _, result := range msg.ToolResults() { + if result.ToolCallID != "" { + resultMap[result.ToolCallID] = result + } + } + } + } + return resultMap +} diff --git a/internal/ui/chat/user.go b/internal/ui/chat/user.go new file mode 100644 index 0000000000000000000000000000000000000000..b3e1bebb16b1bfebe9036b189ca0cfb42c234805 --- /dev/null +++ b/internal/ui/chat/user.go @@ -0,0 +1,122 @@ +package chat + +import ( + "fmt" + "path/filepath" + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/message" + "github.com/charmbracelet/crush/internal/ui/common" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/x/ansi" +) + +// UserMessageItem represents a user message in the chat UI. +type UserMessageItem struct { + *highlightableMessageItem + *cachedMessageItem + message *message.Message + sty *styles.Styles + focused bool +} + +// NewUserMessageItem creates a new UserMessageItem. +func NewUserMessageItem(sty *styles.Styles, message *message.Message) MessageItem { + return &UserMessageItem{ + highlightableMessageItem: defaultHighlighter(sty), + cachedMessageItem: &cachedMessageItem{}, + message: message, + sty: sty, + focused: false, + } +} + +// Render implements MessageItem. +func (m *UserMessageItem) Render(width int) string { + cappedWidth := cappedMessageWidth(width) + + style := m.sty.Chat.Message.UserBlurred + if m.focused { + style = m.sty.Chat.Message.UserFocused + } + + content, height, ok := m.getCachedRender(cappedWidth) + // cache hit + if ok { + return style.Render(m.renderHighlighted(content, cappedWidth, height)) + } + + renderer := common.MarkdownRenderer(m.sty, cappedWidth) + + msgContent := strings.TrimSpace(m.message.Content().Text) + result, err := renderer.Render(msgContent) + if err != nil { + content = msgContent + } else { + content = strings.TrimSuffix(result, "\n") + } + + if len(m.message.BinaryContent()) > 0 { + attachmentsStr := m.renderAttachments(cappedWidth) + content = strings.Join([]string{content, "", attachmentsStr}, "\n") + } + + height = lipgloss.Height(content) + m.setCachedRender(content, cappedWidth, height) + return style.Render(m.renderHighlighted(content, cappedWidth, height)) +} + +// SetFocused implements MessageItem. +func (m *UserMessageItem) SetFocused(focused bool) { + m.focused = focused +} + +// ID implements MessageItem. +func (m *UserMessageItem) ID() string { + return m.message.ID +} + +// renderAttachments renders attachments with wrapping if they exceed the width. +// TODO: change the styles here so they match the new design +func (m *UserMessageItem) renderAttachments(width int) string { + const maxFilenameWidth = 10 + + attachments := make([]string, len(m.message.BinaryContent())) + for i, attachment := range m.message.BinaryContent() { + filename := filepath.Base(attachment.Path) + attachments[i] = m.sty.Chat.Message.Attachment.Render(fmt.Sprintf( + " %s %s ", + styles.DocumentIcon, + ansi.Truncate(filename, maxFilenameWidth, "…"), + )) + } + + // Wrap attachments into lines that fit within the width. + var lines []string + var currentLine []string + currentWidth := 0 + + for _, att := range attachments { + attWidth := lipgloss.Width(att) + sepWidth := 1 + if len(currentLine) == 0 { + sepWidth = 0 + } + + if currentWidth+sepWidth+attWidth > width && len(currentLine) > 0 { + lines = append(lines, strings.Join(currentLine, " ")) + currentLine = []string{att} + currentWidth = attWidth + } else { + currentLine = append(currentLine, att) + currentWidth += sepWidth + attWidth + } + } + + if len(currentLine) > 0 { + lines = append(lines, strings.Join(currentLine, " ")) + } + + return strings.Join(lines, "\n") +} diff --git a/internal/ui/dialog/sessions_item.go b/internal/ui/dialog/sessions_item.go index ddd46c94be32ba885963d17f74efd4d8759a96c1..080de32dba34a8fa50f2b40db2d8335fdcb911d9 100644 --- a/internal/ui/dialog/sessions_item.go +++ b/internal/ui/dialog/sessions_item.go @@ -37,7 +37,7 @@ var _ ListItem = &SessionItem{} // Filter returns the filterable value of the session. func (s *SessionItem) Filter() string { - return s.Session.Title + return s.Title } // ID returns the unique identifier of the session. @@ -53,7 +53,7 @@ func (s *SessionItem) SetMatch(m fuzzy.Match) { // Render returns the string representation of the session item. func (s *SessionItem) Render(width int) string { - return renderItem(s.t, s.Session.Title, s.Session.UpdatedAt, s.focused, width, s.cache, &s.m) + return renderItem(s.t, s.Title, s.UpdatedAt, s.focused, width, s.cache, &s.m) } func renderItem(t *styles.Styles, title string, updatedAt int64, focused bool, width int, cache map[int]string, m *fuzzy.Match) string { diff --git a/internal/ui/list/highlight.go b/internal/ui/list/highlight.go index a1454a7edafb3cd623b022eb517593e86b90364e..c61a53a18ffc2aced7f5ec21f31e2fe4f4916522 100644 --- a/internal/ui/list/highlight.go +++ b/internal/ui/list/highlight.go @@ -31,6 +31,14 @@ func Highlight(content string, area image.Rectangle, startLine, startCol, endLin styled := uv.NewStyledString(content) styled.Draw(&buf, area) + // Treat -1 as "end of content" + if endLine < 0 { + endLine = height - 1 + } + if endCol < 0 { + endCol = width + } + for y := startLine; y <= endLine && y < height; y++ { if y >= buf.Height() { break diff --git a/internal/ui/model/chat.go b/internal/ui/model/chat.go index b540b5b6e62d452d4b966b6dc251a182cd543d90..66ae5bc51ad8d835350fc5edb510048d699d3977 100644 --- a/internal/ui/model/chat.go +++ b/internal/ui/model/chat.go @@ -1,6 +1,7 @@ package model import ( + "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/list" uv "github.com/charmbracelet/ultraviolet" @@ -63,7 +64,7 @@ func (m *Chat) PrependItems(items ...list.Item) { } // SetMessages sets the chat messages to the provided list of message items. -func (m *Chat) SetMessages(msgs ...MessageItem) { +func (m *Chat) SetMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { items[i] = msg @@ -73,7 +74,7 @@ func (m *Chat) SetMessages(msgs ...MessageItem) { } // AppendMessages appends a new message item to the chat list. -func (m *Chat) AppendMessages(msgs ...MessageItem) { +func (m *Chat) AppendMessages(msgs ...chat.MessageItem) { items := make([]list.Item, len(msgs)) for i, msg := range msgs { items[i] = msg diff --git a/internal/ui/model/items.go b/internal/ui/model/items.go deleted file mode 100644 index 789208df297fa4ab5ddeaa25651a8ac66ef5812a..0000000000000000000000000000000000000000 --- a/internal/ui/model/items.go +++ /dev/null @@ -1,548 +0,0 @@ -package model - -import ( - "fmt" - "path/filepath" - "strings" - "time" - - "charm.land/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - - "github.com/charmbracelet/crush/internal/config" - "github.com/charmbracelet/crush/internal/message" - "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/list" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/ui/toolrender" -) - -// Identifiable is an interface for items that can provide a unique identifier. -type Identifiable interface { - ID() string -} - -// MessageItem represents a [message.Message] item that can be displayed in the -// UI and be part of a [list.List] identifiable by a unique ID. -type MessageItem interface { - list.Item - list.Item - Identifiable -} - -// MessageContentItem represents rendered message content (text, markdown, errors, etc). -type MessageContentItem struct { - id string - content string - role message.MessageRole - isMarkdown bool - maxWidth int - sty *styles.Styles -} - -// NewMessageContentItem creates a new message content item. -func NewMessageContentItem(id, content string, role message.MessageRole, isMarkdown bool, sty *styles.Styles) *MessageContentItem { - m := &MessageContentItem{ - id: id, - content: content, - isMarkdown: isMarkdown, - role: role, - maxWidth: 120, - sty: sty, - } - return m -} - -// ID implements Identifiable. -func (m *MessageContentItem) ID() string { - return m.id -} - -// FocusStyle returns the focus style. -func (m *MessageContentItem) FocusStyle() lipgloss.Style { - if m.role == message.User { - return m.sty.Chat.Message.UserFocused - } - return m.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (m *MessageContentItem) BlurStyle() lipgloss.Style { - if m.role == message.User { - return m.sty.Chat.Message.UserBlurred - } - return m.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (m *MessageContentItem) HighlightStyle() lipgloss.Style { - return m.sty.TextSelection -} - -// Render renders the content at the given width, using cache if available. -// -// It implements [list.Item]. -func (m *MessageContentItem) Render(width int) string { - contentWidth := width - // Cap width to maxWidth for markdown - cappedWidth := contentWidth - if m.isMarkdown { - cappedWidth = min(contentWidth, m.maxWidth) - } - - var rendered string - if m.isMarkdown { - renderer := common.MarkdownRenderer(m.sty, cappedWidth) - result, err := renderer.Render(m.content) - if err != nil { - rendered = m.content - } else { - rendered = strings.TrimSuffix(result, "\n") - } - } else { - rendered = m.content - } - - return rendered -} - -// ToolCallItem represents a rendered tool call with its header and content. -type ToolCallItem struct { - id string - toolCall message.ToolCall - toolResult message.ToolResult - cancelled bool - isNested bool - maxWidth int - sty *styles.Styles -} - -// cachedToolRender stores both the rendered string and its height. -type cachedToolRender struct { - content string - height int -} - -// NewToolCallItem creates a new tool call item. -func NewToolCallItem(id string, toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool, isNested bool, sty *styles.Styles) *ToolCallItem { - t := &ToolCallItem{ - id: id, - toolCall: toolCall, - toolResult: toolResult, - cancelled: cancelled, - isNested: isNested, - maxWidth: 120, - sty: sty, - } - return t -} - -// generateCacheKey creates a key that changes when tool call content changes. -func generateCacheKey(toolCall message.ToolCall, toolResult message.ToolResult, cancelled bool) string { - // Simple key based on result state - when result arrives or changes, key changes - return fmt.Sprintf("%s:%s:%v", toolCall.ID, toolResult.ToolCallID, cancelled) -} - -// ID implements Identifiable. -func (t *ToolCallItem) ID() string { - return t.id -} - -// FocusStyle returns the focus style. -func (t *ToolCallItem) FocusStyle() lipgloss.Style { - return t.sty.Chat.Message.ToolCallFocused -} - -// BlurStyle returns the blur style. -func (t *ToolCallItem) BlurStyle() lipgloss.Style { - return t.sty.Chat.Message.ToolCallBlurred -} - -// HighlightStyle returns the highlight style. -func (t *ToolCallItem) HighlightStyle() lipgloss.Style { - return t.sty.TextSelection -} - -// Render implements list.Item. -func (t *ToolCallItem) Render(width int) string { - // Render the tool call - ctx := &toolrender.RenderContext{ - Call: t.toolCall, - Result: t.toolResult, - Cancelled: t.cancelled, - IsNested: t.isNested, - Width: width, - Styles: t.sty, - } - - rendered := toolrender.Render(ctx) - return rendered -} - -// AttachmentItem represents a file attachment in a user message. -type AttachmentItem struct { - id string - filename string - path string - sty *styles.Styles -} - -// NewAttachmentItem creates a new attachment item. -func NewAttachmentItem(id, filename, path string, sty *styles.Styles) *AttachmentItem { - a := &AttachmentItem{ - id: id, - filename: filename, - path: path, - sty: sty, - } - return a -} - -// ID implements Identifiable. -func (a *AttachmentItem) ID() string { - return a.id -} - -// FocusStyle returns the focus style. -func (a *AttachmentItem) FocusStyle() lipgloss.Style { - return a.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (a *AttachmentItem) BlurStyle() lipgloss.Style { - return a.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (a *AttachmentItem) HighlightStyle() lipgloss.Style { - return a.sty.TextSelection -} - -// Render implements list.Item. -func (a *AttachmentItem) Render(width int) string { - const maxFilenameWidth = 10 - content := a.sty.Chat.Message.Attachment.Render(fmt.Sprintf( - " %s %s ", - styles.DocumentIcon, - ansi.Truncate(a.filename, maxFilenameWidth, "..."), - )) - - return content - - // return a.RenderWithHighlight(content, width, a.CurrentStyle()) -} - -// ThinkingItem represents thinking/reasoning content in assistant messages. -type ThinkingItem struct { - id string - thinking string - duration time.Duration - finished bool - maxWidth int - sty *styles.Styles -} - -// NewThinkingItem creates a new thinking item. -func NewThinkingItem(id, thinking string, duration time.Duration, finished bool, sty *styles.Styles) *ThinkingItem { - t := &ThinkingItem{ - id: id, - thinking: thinking, - duration: duration, - finished: finished, - maxWidth: 120, - sty: sty, - } - return t -} - -// ID implements Identifiable. -func (t *ThinkingItem) ID() string { - return t.id -} - -// FocusStyle returns the focus style. -func (t *ThinkingItem) FocusStyle() lipgloss.Style { - return t.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (t *ThinkingItem) BlurStyle() lipgloss.Style { - return t.sty.Chat.Message.AssistantBlurred -} - -// HighlightStyle returns the highlight style. -func (t *ThinkingItem) HighlightStyle() lipgloss.Style { - return t.sty.TextSelection -} - -// Render implements list.Item. -func (t *ThinkingItem) Render(width int) string { - cappedWidth := min(width, t.maxWidth) - - renderer := common.PlainMarkdownRenderer(cappedWidth - 1) - rendered, err := renderer.Render(t.thinking) - if err != nil { - // Fallback to line-by-line rendering - lines := strings.Split(t.thinking, "\n") - var content strings.Builder - lineStyle := t.sty.PanelMuted - for i, line := range lines { - if line == "" { - continue - } - content.WriteString(lineStyle.Width(cappedWidth).Render(line)) - if i < len(lines)-1 { - content.WriteString("\n") - } - } - rendered = content.String() - } - - fullContent := strings.TrimSpace(rendered) - - // Add footer if finished - if t.finished && t.duration > 0 { - footer := t.sty.Chat.Message.ThinkingFooter.Render(fmt.Sprintf("Thought for %s", t.duration.String())) - fullContent = lipgloss.JoinVertical(lipgloss.Left, fullContent, "", footer) - } - - result := t.sty.PanelMuted.Width(cappedWidth).Padding(0, 1).Render(fullContent) - - return result -} - -// SectionHeaderItem represents a section header (e.g., assistant info). -type SectionHeaderItem struct { - id string - modelName string - duration time.Duration - isSectionHeader bool - sty *styles.Styles - content string -} - -// NewSectionHeaderItem creates a new section header item. -func NewSectionHeaderItem(id, modelName string, duration time.Duration, sty *styles.Styles) *SectionHeaderItem { - s := &SectionHeaderItem{ - id: id, - modelName: modelName, - duration: duration, - isSectionHeader: true, - sty: sty, - } - return s -} - -// ID implements Identifiable. -func (s *SectionHeaderItem) ID() string { - return s.id -} - -// IsSectionHeader returns true if this is a section header. -func (s *SectionHeaderItem) IsSectionHeader() bool { - return s.isSectionHeader -} - -// FocusStyle returns the focus style. -func (s *SectionHeaderItem) FocusStyle() lipgloss.Style { - return s.sty.Chat.Message.AssistantFocused -} - -// BlurStyle returns the blur style. -func (s *SectionHeaderItem) BlurStyle() lipgloss.Style { - return s.sty.Chat.Message.AssistantBlurred -} - -// Render implements list.Item. -func (s *SectionHeaderItem) Render(width int) string { - content := fmt.Sprintf("%s %s %s", - s.sty.Subtle.Render(styles.ModelIcon), - s.sty.Muted.Render(s.modelName), - s.sty.Subtle.Render(s.duration.String()), - ) - - return s.sty.Chat.Message.SectionHeader.Render(content) -} - -// GetMessageItems extracts [MessageItem]s from a [message.Message]. It returns -// all parts of the message as [MessageItem]s. -// -// For assistant messages with tool calls, pass a toolResults map to link results. -// Use BuildToolResultMap to create this map from all messages in a session. -func GetMessageItems(sty *styles.Styles, msg *message.Message, toolResults map[string]message.ToolResult) []MessageItem { - var items []MessageItem - - // Skip tool result messages - they're displayed inline with tool calls - if msg.Role == message.Tool { - return items - } - - // Process user messages - if msg.Role == message.User { - // Add main text content - content := msg.Content().String() - if content != "" { - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - content, - msg.Role, - true, // User messages are markdown - sty, - ) - items = append(items, item) - } - - // Add attachments - for i, attachment := range msg.BinaryContent() { - filename := filepath.Base(attachment.Path) - item := NewAttachmentItem( - fmt.Sprintf("%s-attachment-%d", msg.ID, i), - filename, - attachment.Path, - sty, - ) - items = append(items, item) - } - - return items - } - - // Process assistant messages - if msg.Role == message.Assistant { - // Check if we need to add a section header - finishData := msg.FinishPart() - if finishData != nil && msg.Model != "" { - model := config.Get().GetModel(msg.Provider, msg.Model) - modelName := "Unknown Model" - if model != nil { - modelName = model.Name - } - - // Calculate duration (this would need the last user message time) - duration := time.Duration(0) - if finishData.Time > 0 { - duration = time.Duration(finishData.Time-msg.CreatedAt) * time.Second - } - - header := NewSectionHeaderItem( - fmt.Sprintf("%s-header", msg.ID), - modelName, - duration, - sty, - ) - items = append(items, header) - } - - // Add thinking content if present - reasoning := msg.ReasoningContent() - if strings.TrimSpace(reasoning.Thinking) != "" { - duration := time.Duration(0) - if reasoning.StartedAt > 0 && reasoning.FinishedAt > 0 { - duration = time.Duration(reasoning.FinishedAt-reasoning.StartedAt) * time.Second - } - - item := NewThinkingItem( - fmt.Sprintf("%s-thinking", msg.ID), - reasoning.Thinking, - duration, - reasoning.FinishedAt > 0, - sty, - ) - items = append(items, item) - } - - // Add main text content - content := msg.Content().String() - finished := msg.IsFinished() - - // Handle special finish states - if finished && content == "" && finishData != nil { - switch finishData.Reason { - case message.FinishReasonEndTurn: - // No content to show - case message.FinishReasonCanceled: - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - "*Canceled*", - msg.Role, - true, - sty, - ) - items = append(items, item) - case message.FinishReasonError: - // Render error - errTag := sty.Chat.Message.ErrorTag.Render("ERROR") - truncated := ansi.Truncate(finishData.Message, 100, "...") - title := fmt.Sprintf("%s %s", errTag, sty.Chat.Message.ErrorTitle.Render(truncated)) - details := sty.Chat.Message.ErrorDetails.Render(finishData.Details) - errorContent := fmt.Sprintf("%s\n\n%s", title, details) - - item := NewMessageContentItem( - fmt.Sprintf("%s-error", msg.ID), - errorContent, - msg.Role, - false, - sty, - ) - items = append(items, item) - } - } else if content != "" { - item := NewMessageContentItem( - fmt.Sprintf("%s-content", msg.ID), - content, - msg.Role, - true, // Assistant messages are markdown - sty, - ) - items = append(items, item) - } - - // Add tool calls - toolCalls := msg.ToolCalls() - - // Use passed-in tool results map (if nil, use empty map) - resultMap := toolResults - if resultMap == nil { - resultMap = make(map[string]message.ToolResult) - } - - for _, tc := range toolCalls { - result, hasResult := resultMap[tc.ID] - if !hasResult { - result = message.ToolResult{} - } - - item := NewToolCallItem( - fmt.Sprintf("%s-tool-%s", msg.ID, tc.ID), - tc, - result, - false, // cancelled state would need to be tracked separately - false, // nested state would be detected from tool results - sty, - ) - - items = append(items, item) - } - - return items - } - - return items -} - -// BuildToolResultMap creates a map of tool call IDs to their results from a list of messages. -// Tool result messages (role == message.Tool) contain the results that should be linked -// to tool calls in assistant messages. -func BuildToolResultMap(messages []*message.Message) map[string]message.ToolResult { - resultMap := make(map[string]message.ToolResult) - for _, msg := range messages { - if msg.Role == message.Tool { - for _, result := range msg.ToolResults() { - if result.ToolCallID != "" { - resultMap[result.ToolCallID] = result - } - } - } - } - return resultMap -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 2c0ab9e85c1b57803c8424bb29eb5a5c0774357e..93367cdd05842279984027f228e053d1504f1c52 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -26,9 +26,9 @@ import ( "github.com/charmbracelet/crush/internal/permission" "github.com/charmbracelet/crush/internal/pubsub" "github.com/charmbracelet/crush/internal/session" - "github.com/charmbracelet/crush/internal/tui/components/chat" "github.com/charmbracelet/crush/internal/tui/components/dialogs/filepicker" "github.com/charmbracelet/crush/internal/tui/util" + "github.com/charmbracelet/crush/internal/ui/chat" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" "github.com/charmbracelet/crush/internal/ui/logo" @@ -203,13 +203,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, uiutil.ReportError(err)) break } + m.setSessionMessages(msgs) - if cmd := m.handleMessageEvents(msgs...); cmd != nil { - cmds = append(cmds, cmd) - } case pubsub.Event[message.Message]: // TODO: Finish implementing me - cmds = append(cmds, m.handleMessageEvents(msg.Payload)) + // cmds = append(cmds, m.setMessageEvents(msg.Payload)) case pubsub.Event[history.File]: cmds = append(cmds, m.handleFileEvent(msg.Payload)) case pubsub.Event[app.LSPEvent]: @@ -337,29 +335,24 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m *UI) handleMessageEvents(msgs ...message.Message) tea.Cmd { +// setSessionMessages sets the messages for the current session in the chat +func (m *UI) setSessionMessages(msgs []message.Message) { // Build tool result map to link tool calls with their results msgPtrs := make([]*message.Message, len(msgs)) for i := range msgs { msgPtrs[i] = &msgs[i] } - toolResultMap := BuildToolResultMap(msgPtrs) + toolResultMap := chat.BuildToolResultMap(msgPtrs) // Add messages to chat with linked tool results - items := make([]MessageItem, 0, len(msgs)*2) + items := make([]chat.MessageItem, 0, len(msgs)*2) for _, msg := range msgPtrs { - items = append(items, GetMessageItems(m.com.Styles, msg, toolResultMap)...) + items = append(items, chat.GetMessageItems(m.com.Styles, msg, toolResultMap)...) } - if m.session == nil || m.session.ID == "" { - m.chat.SetMessages(items...) - } else { - m.chat.AppendMessages(items...) - } + m.chat.SetMessages(items...) m.chat.ScrollToBottom() m.chat.SelectLast() - - return nil } func (m *UI) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { @@ -1179,7 +1172,7 @@ func (m *UI) sendMessage(content string, attachments []message.Attachment) tea.C return uiutil.ReportError(err) } session = newSession - cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session))) + cmds = append(cmds, m.loadSession(session.ID)) } if m.com.App.AgentCoordinator == nil { return util.ReportError(fmt.Errorf("coder agent is not initialized"))