on main help sign in
chore: add assistant section
97147f0Kujtim Hoxha
internal/tui/components/chat/chat.go+13−4
@@ -272,7 +272,7 @@ func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, me
272 assistantIndex = i
272 assistantIndex = i
273 }
273 }
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
274 } else if tc, ok := item.(messages.ToolCallCmp); ok {
275− if tc.ParentMessageId() == messageID {
275+ if tc.ParentMessageID() == messageID {
276 toolCalls[i] = tc
276 toolCalls[i] = tc
277 }
277 }
278 }
278 }
@@ -295,9 +295,17 @@ func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assi
295 assistantIndex,
295 assistantIndex,
296 messages.NewMessageCmp(
296 messages.NewMessageCmp(
297 msg,
297 msg,
298− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
299 ),
298 ),
300 )
299 )
300+
301+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
302+ m.listCmp.AppendItem(
303+ messages.NewAssistantSection(
304+ msg,
305+ time.Unix(m.lastUserMessageTime, 0),
306+ ),
307+ )
308+ }
301 } else if hasToolCallsOnly {
309 } else if hasToolCallsOnly {
302 m.listCmp.DeleteItem(assistantIndex)
310 m.listCmp.DeleteItem(assistantIndex)
303 }
311 }
@@ -347,7 +355,6 @@ func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd
347 cmd := m.listCmp.AppendItem(
355 cmd := m.listCmp.AppendItem(
348 messages.NewMessageCmp(
356 messages.NewMessageCmp(
349 msg,
357 msg,
350− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
351 ),
358 ),
352 )
359 )
353 cmds = append(cmds, cmd)
360 cmds = append(cmds, cmd)
@@ -412,6 +419,9 @@ func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message,
412 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
419 uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
413 case message.Assistant:
420 case message.Assistant:
414 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
421 uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
422+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonEndTurn {
423+ uiMessages = append(uiMessages, messages.NewAssistantSection(msg, time.Unix(m.lastUserMessageTime, 0)))
424+ }
415 }
425 }
416 }
426 }
417
427
@@ -428,7 +438,6 @@ func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResult
428 uiMessages,
438 uiMessages,
429 messages.NewMessageCmp(
439 messages.NewMessageCmp(
430 msg,
440 msg,
431− messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
432 ),
441 ),
433 )
442 )
434 }
443 }
internal/tui/components/chat/messages/messages.go+63−43
@@ -8,13 +8,14 @@ import (
8
8
9 "github.com/charmbracelet/bubbles/v2/spinner"
9 "github.com/charmbracelet/bubbles/v2/spinner"
10 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
11− "github.com/charmbracelet/crush/internal/llm/models"
12 "github.com/charmbracelet/lipgloss/v2"
11 "github.com/charmbracelet/lipgloss/v2"
13
12
13+ "github.com/charmbracelet/crush/internal/llm/models"
14 "github.com/charmbracelet/crush/internal/message"
14 "github.com/charmbracelet/crush/internal/message"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
15 "github.com/charmbracelet/crush/internal/tui/components/anim"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
16 "github.com/charmbracelet/crush/internal/tui/components/core"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
17 "github.com/charmbracelet/crush/internal/tui/components/core/layout"
18+ "github.com/charmbracelet/crush/internal/tui/components/core/list"
18 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/styles"
19 "github.com/charmbracelet/crush/internal/tui/util"
20 "github.com/charmbracelet/crush/internal/tui/util"
20)
21)
@@ -37,32 +38,17 @@ type messageCmp struct {
37 focused bool // Focus state for border styling
38 focused bool // Focus state for border styling
38
39
39 // Core message data and state
40 // Core message data and state
40− message message.Message // The underlying message content
41+ message message.Message // The underlying message content
41− spinning bool // Whether to show loading animation
42+ spinning bool // Whether to show loading animation
42− anim util.Model // Animation component for loading states
43+ 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− }
55}
44}
56
45
57// NewMessageCmp creates a new message component with the given message and options
46// 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 {
59 m := &messageCmp{
48 m := &messageCmp{
60 message: msg,
49 message: msg,
61 anim: anim.New(15, ""),
50 anim: anim.New(15, ""),
62 }
51 }
63− for _, opt := range opts {
64− opt(m)
65− }
66 return m
52 return m
67}
53}
68
54
@@ -145,32 +131,10 @@ func (msg *messageCmp) style() lipgloss.Style {
145// renderAssistantMessage renders assistant messages with optional footer information.
131// renderAssistantMessage renders assistant messages with optional footer information.
146// Shows model name, response time, and finish reason when the message is complete.
132// Shows model name, response time, and finish reason when the message is complete.
147func (m *messageCmp) renderAssistantMessage() string {
133func (m *messageCmp) renderAssistantMessage() string {
148− t := styles.CurrentTheme()
149 parts := []string{
134 parts := []string{
150 m.markdownContent(),
135 m.markdownContent(),
151 }
136 }
152
137
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−
174 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
138 joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
175 return m.style().Render(joined)
139 return m.style().Render(joined)
176}
140}
@@ -225,7 +189,7 @@ func (m *messageCmp) markdownContent() string {
225 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
189 } else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
226 // Sometimes the LLMs respond with no content when they think the previous tool result
190 // Sometimes the LLMs respond with no content when they think the previous tool result
227 // provides the requested question
191 // provides the requested question
228− content = "*Finished without output*"
192+ content = ""
229 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
193 } else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
230 content = "*Canceled*"
194 content = "*Canceled*"
231 }
195 }
@@ -287,3 +251,59 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
287func (m *messageCmp) Spinning() bool {
251func (m *messageCmp) Spinning() bool {
288 return m.spinning
252 return m.spinning
289}
253}
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+}
internal/tui/components/chat/messages/tool.go+4−7
@@ -25,7 +25,7 @@ type ToolCallCmp interface {
25 SetToolResult(message.ToolResult) // Update tool result
25 SetToolResult(message.ToolResult) // Update tool result
26 SetToolCall(message.ToolCall) // Update tool call
26 SetToolCall(message.ToolCall) // Update tool call
27 SetCancelled() // Mark as cancelled
27 SetCancelled() // Mark as cancelled
28− ParentMessageId() string // Get parent message ID
28+ ParentMessageID() string // Get parent message ID
29 Spinning() bool // Animation state for pending tools
29 Spinning() bool // Animation state for pending tools
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
30 GetNestedToolCalls() []ToolCallCmp // Get nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
31 SetNestedToolCalls([]ToolCallCmp) // Set nested tool calls
@@ -137,9 +137,6 @@ func (m *toolCallCmp) View() tea.View {
137 box := m.style()
137 box := m.style()
138
138
139 if !m.call.Finished && !m.cancelled {
139 if !m.call.Finished && !m.cancelled {
140− if m.isNested {
141− return tea.NewView(box.Render(m.renderPending()))
142− }
143 return tea.NewView(box.Render(m.renderPending()))
140 return tea.NewView(box.Render(m.renderPending()))
144 }
141 }
145
142
@@ -166,8 +163,8 @@ func (m *toolCallCmp) SetToolCall(call message.ToolCall) {
166 }
163 }
167}
164}
168
165
169−// ParentMessageId returns the ID of the message that initiated this tool call
166+// ParentMessageID returns the ID of the message that initiated this tool call
170−func (m *toolCallCmp) ParentMessageId() string {
167+func (m *toolCallCmp) ParentMessageID() string {
171 return m.parentMessageID
168 return m.parentMessageID
172}
169}
173
170
@@ -212,7 +209,7 @@ func (m *toolCallCmp) renderPending() string {
212 t := styles.CurrentTheme()
209 t := styles.CurrentTheme()
213 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
210 icon := t.S().Base.Foreground(t.GreenDark).Render(styles.ToolPending)
214 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
211 tool := t.S().Base.Foreground(t.Blue).Render(prettifyToolName(m.call.Name))
215− return fmt.Sprintf("%s %s: %s", icon, tool, m.anim.View())
212+ return fmt.Sprintf("%s %s %s", icon, tool, m.anim.View())
216}
213}
217
214
218// style returns the lipgloss style for the tool call component.
215// style returns the lipgloss style for the tool call component.
todos.md+9
@@ -20,7 +20,16 @@
20- [ ] Parallel tool calls and permissions
20- [ ] Parallel tool calls and permissions
21 - [ ] Run the tools in parallel and add results in parallel
21 - [ ] Run the tools in parallel and add results in parallel
22 - [ ] Show multiple permissions dialogs
22 - [ ] Show multiple permissions dialogs
23+- [ ] Add another space around buttons
24+- [ ] Completions
25+ - [ ] Should change the help to show the completions stuff
26+ - [ ] Should make it wider
27+ - [ ] Tab and ctrl+y should accept
28+ - [ ] Words should line up
29+ - [ ] If there are no completions and cick tab/ctrl+y/enter it should close it
23- [ ] Investigate messages issues
30- [ ] Investigate messages issues
31+ - [ ] Make the agent separator look like the
32+ - [ ] Cleanup tool calls (watch all states)
24 - [ ] Weird behavior sometimes the message does not update
33 - [ ] Weird behavior sometimes the message does not update
25 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
34 - [ ] Message length (I saw the message go beyond the correct length when there are errors)
26 - [ ] Address UX issues
35 - [ ] Address UX issues