@@ -8,13 +8,14 @@ import (
88
99 "github.com/charmbracelet/bubbles/v2/spinner"
1010 tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/llm/models"
1211 "github.com/charmbracelet/lipgloss/v2"
1312
13+ "github.com/charmbracelet/crush/internal/llm/models"
1414 "github.com/charmbracelet/crush/internal/message"
1515 "github.com/charmbracelet/crush/internal/tui/components/anim"
1616 "github.com/charmbracelet/crush/internal/tui/components/core"
1717 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
1819 "github.com/charmbracelet/crush/internal/tui/styles"
1920 "github.com/charmbracelet/crush/internal/tui/util"
2021)
@@ -37,32 +38,17 @@ type messageCmp struct {
3738 focused bool // Focus state for border styling
3839
3940 // Core message data and state
40− message message.Message // The underlying message content
41− spinning bool // Whether to show loading animation
42− anim util.Model // Animation component for loading states
43− lastUserMessageTime time.Time // Used for calculating response duration
44−}
45−
46−// MessageOption provides functional options for configuring message components
47−type MessageOption func(*messageCmp)
48−
49−// WithLastUserMessageTime sets the timestamp of the last user message
50−// for calculating assistant response duration
51−func WithLastUserMessageTime(t time.Time) MessageOption {
52− return func(m *messageCmp) {
53− m.lastUserMessageTime = t
54− }
41+ message message.Message // The underlying message content
42+ spinning bool // Whether to show loading animation
43+ anim util.Model // Animation component for loading states
5544}
5645
5746// NewMessageCmp creates a new message component with the given message and options
58−func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
47+func NewMessageCmp(msg message.Message) MessageCmp {
5948 m := &messageCmp{
6049 message: msg,
6150 anim: anim.New(15, ""),
6251 }
63− for _, opt := range opts {
64− opt(m)
65− }
6652 return m
6753}
6854
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
145131// renderAssistantMessage renders assistant messages with optional footer information.
146132// Shows model name, response time, and finish reason when the message is complete.
147133func (m *messageCmp) renderAssistantMessage() string {
148− t := styles.CurrentTheme()
149134 parts := []string{
150135 m.markdownContent(),
151136 }
152137
153− finished := m.message.IsFinished()
154− finishData := m.message.FinishPart()
155− // Only show the footer if the message is not a tool call
156− if finished && finishData.Reason != message.FinishReasonToolUse {
157− infoMsg := ""
158− switch finishData.Reason {
159− case message.FinishReasonEndTurn:
160− finishTime := time.Unix(finishData.Time, 0)
161− duration := finishTime.Sub(m.lastUserMessageTime)
162− infoMsg = duration.String()
163− case message.FinishReasonCanceled:
164− infoMsg = "canceled"
165− case message.FinishReasonError:
166− infoMsg = "error"
167− case message.FinishReasonPermissionDenied:
168− infoMsg = "permission denied"
169− }
170− assistant := t.S().Muted.Render(fmt.Sprintf("%s %s (%s)", styles.ModelIcon, models.SupportedModels[m.message.Model].Name, infoMsg))
171− parts = append(parts, core.Section(assistant, m.textWidth()))
172− }
173−
174138 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175139 return m.style().Render(joined)
176140}
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
225189 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226190 // Sometimes the LLMs respond with no content when they think the previous tool result
227191 // provides the requested question
228− content = "*Finished without output*"
192+ content = ""
229193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230194 content = "*Canceled*"
231195 }
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
287251func (m *messageCmp) Spinning() bool {
288252 return m.spinning
289253}
254+
255+type AssistantSection interface {
256+ util.Model
257+ layout.Sizeable
258+ list.SectionHeader
259+}
260+type assistantSectionModel struct {
261+ width int
262+ message message.Message
263+ lastUserMessageTime time.Time
264+}
265+
266+func NewAssistantSection(message message.Message, lastUserMessageTime time.Time) AssistantSection {
267+ return &assistantSectionModel{
268+ width: 0,
269+ message: message,
270+ lastUserMessageTime: lastUserMessageTime,
271+ }
272+}
273+
274+func (m *assistantSectionModel) Init() tea.Cmd {
275+ return nil
276+}
277+
278+func (m *assistantSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
279+ return m, nil
280+}
281+
282+func (m *assistantSectionModel) View() tea.View {
283+ t := styles.CurrentTheme()
284+ finishData := m.message.FinishPart()
285+ finishTime := time.Unix(finishData.Time, 0)
286+ duration := finishTime.Sub(m.lastUserMessageTime)
287+ infoMsg := t.S().Subtle.Render(duration.String())
288+ icon := t.S().Subtle.Render(styles.ModelIcon)
289+ model := t.S().Muted.Render(models.SupportedModels[m.message.Model].Name)
290+ assistant := fmt.Sprintf("%s %s %s", icon, model, infoMsg)
291+ return tea.NewView(
292+ t.S().Base.PaddingLeft(2).Render(
293+ core.Section(assistant, m.width-1),
294+ ),
295+ )
296+}
297+
298+func (m *assistantSectionModel) GetSize() (int, int) {
299+ return m.width, 1
300+}
301+
302+func (m *assistantSectionModel) SetSize(width int, height int) tea.Cmd {
303+ m.width = width
304+ return nil
305+}
306+
307+func (m *assistantSectionModel) IsSectionHeader() bool {
308+ return true
309+}