messages.go

  1package chat
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"math"
  8	"strings"
  9	"time"
 10
 11	"github.com/charmbracelet/bubbles/key"
 12	"github.com/charmbracelet/bubbles/spinner"
 13	"github.com/charmbracelet/bubbles/viewport"
 14	tea "github.com/charmbracelet/bubbletea"
 15	"github.com/charmbracelet/glamour"
 16	"github.com/charmbracelet/lipgloss"
 17	"github.com/charmbracelet/x/ansi"
 18	"github.com/kujtimiihoxha/opencode/internal/app"
 19	"github.com/kujtimiihoxha/opencode/internal/llm/agent"
 20	"github.com/kujtimiihoxha/opencode/internal/llm/models"
 21	"github.com/kujtimiihoxha/opencode/internal/llm/tools"
 22	"github.com/kujtimiihoxha/opencode/internal/logging"
 23	"github.com/kujtimiihoxha/opencode/internal/message"
 24	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 25	"github.com/kujtimiihoxha/opencode/internal/session"
 26	"github.com/kujtimiihoxha/opencode/internal/tui/layout"
 27	"github.com/kujtimiihoxha/opencode/internal/tui/styles"
 28	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 29)
 30
 31type uiMessageType int
 32
 33const (
 34	userMessageType uiMessageType = iota
 35	assistantMessageType
 36	toolMessageType
 37)
 38
 39// messagesTickMsg is a message sent by the timer to refresh messages
 40type messagesTickMsg time.Time
 41
 42type uiMessage struct {
 43	ID          string
 44	messageType uiMessageType
 45	position    int
 46	height      int
 47	content     string
 48}
 49
 50type messagesCmp struct {
 51	app           *app.App
 52	width, height int
 53	writingMode   bool
 54	viewport      viewport.Model
 55	session       session.Session
 56	messages      []message.Message
 57	uiMessages    []uiMessage
 58	currentMsgID  string
 59	renderer      *glamour.TermRenderer
 60	focusRenderer *glamour.TermRenderer
 61	cachedContent map[string]string
 62	spinner       spinner.Model
 63	needsRerender bool
 64}
 65
 66func (m *messagesCmp) Init() tea.Cmd {
 67	return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
 68}
 69
 70func (m *messagesCmp) tickMessages() tea.Cmd {
 71	return tea.Tick(time.Second, func(t time.Time) tea.Msg {
 72		return messagesTickMsg(t)
 73	})
 74}
 75
 76func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 77	var cmds []tea.Cmd
 78	switch msg := msg.(type) {
 79	case messagesTickMsg:
 80		// Refresh messages if we have an active session
 81		if m.session.ID != "" {
 82			messages, err := m.app.Messages.List(context.Background(), m.session.ID)
 83			if err == nil {
 84				m.messages = messages
 85				m.needsRerender = true
 86			}
 87		}
 88		// Continue ticking
 89		cmds = append(cmds, m.tickMessages())
 90	case EditorFocusMsg:
 91		m.writingMode = bool(msg)
 92	case SessionSelectedMsg:
 93		if msg.ID != m.session.ID {
 94			cmd := m.SetSession(msg)
 95			m.needsRerender = true
 96			return m, cmd
 97		}
 98		return m, nil
 99	case SessionClearedMsg:
100		m.session = session.Session{}
101		m.messages = make([]message.Message, 0)
102		m.currentMsgID = ""
103		m.needsRerender = true
104		m.cachedContent = make(map[string]string)
105		return m, nil
106
107	case tea.KeyMsg:
108		if m.writingMode {
109			return m, nil
110		}
111	case pubsub.Event[message.Message]:
112		if msg.Type == pubsub.CreatedEvent {
113			if msg.Payload.SessionID == m.session.ID {
114				// check if message exists
115
116				messageExists := false
117				for _, v := range m.messages {
118					if v.ID == msg.Payload.ID {
119						messageExists = true
120						break
121					}
122				}
123
124				if !messageExists {
125					// If we have messages, ensure the previous last message is not cached
126					if len(m.messages) > 0 {
127						lastMsgID := m.messages[len(m.messages)-1].ID
128						delete(m.cachedContent, lastMsgID)
129					}
130
131					m.messages = append(m.messages, msg.Payload)
132					delete(m.cachedContent, m.currentMsgID)
133					m.currentMsgID = msg.Payload.ID
134					m.needsRerender = true
135				}
136			}
137			for _, v := range m.messages {
138				for _, c := range v.ToolCalls() {
139					if c.ID == msg.Payload.SessionID {
140						m.needsRerender = true
141					}
142				}
143			}
144		} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
145			logging.Debug("Message", "finish", msg.Payload.FinishReason())
146			for i, v := range m.messages {
147				if v.ID == msg.Payload.ID {
148					m.messages[i] = msg.Payload
149					delete(m.cachedContent, msg.Payload.ID)
150
151					// If this is the last message, ensure it's not cached
152					if i == len(m.messages)-1 {
153						delete(m.cachedContent, msg.Payload.ID)
154					}
155
156					m.needsRerender = true
157					break
158				}
159			}
160		}
161	}
162
163	oldPos := m.viewport.YPosition
164	u, cmd := m.viewport.Update(msg)
165	m.viewport = u
166	m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
167	cmds = append(cmds, cmd)
168
169	spinner, cmd := m.spinner.Update(msg)
170	m.spinner = spinner
171	cmds = append(cmds, cmd)
172
173	if m.needsRerender {
174		m.renderView()
175		if len(m.messages) > 0 {
176			if msg, ok := msg.(pubsub.Event[message.Message]); ok {
177				if (msg.Type == pubsub.CreatedEvent) ||
178					(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
179					m.viewport.GotoBottom()
180				}
181			}
182		}
183		m.needsRerender = false
184	}
185	return m, tea.Batch(cmds...)
186}
187
188func (m *messagesCmp) IsAgentWorking() bool {
189	return m.app.CoderAgent.IsSessionBusy(m.session.ID)
190}
191
192func (m *messagesCmp) renderSimpleMessage(msg message.Message, info ...string) string {
193	// Check if this is the last message in the list
194	isLastMessage := len(m.messages) > 0 && m.messages[len(m.messages)-1].ID == msg.ID
195
196	// Only use cache for non-last messages
197	if !isLastMessage {
198		if v, ok := m.cachedContent[msg.ID]; ok {
199			return v
200		}
201	}
202
203	style := styles.BaseStyle.
204		Width(m.width).
205		BorderLeft(true).
206		Foreground(styles.ForgroundDim).
207		BorderForeground(styles.ForgroundDim).
208		BorderStyle(lipgloss.ThickBorder())
209
210	renderer := m.renderer
211	if msg.ID == m.currentMsgID {
212		style = style.
213			Foreground(styles.Forground).
214			BorderForeground(styles.Blue).
215			BorderStyle(lipgloss.ThickBorder())
216		renderer = m.focusRenderer
217	}
218	c, _ := renderer.Render(msg.Content().String())
219	parts := []string{
220		styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
221	}
222	// remove newline at the end
223	parts[0] = strings.TrimSuffix(parts[0], "\n")
224	if len(info) > 0 {
225		parts = append(parts, info...)
226	}
227	rendered := style.Render(
228		lipgloss.JoinVertical(
229			lipgloss.Left,
230			parts...,
231		),
232	)
233
234	// Only cache if it's not the last message
235	if !isLastMessage {
236		m.cachedContent[msg.ID] = rendered
237	}
238
239	return rendered
240}
241
242func formatTimeDifference(unixTime1, unixTime2 int64) string {
243	diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
244
245	if diffSeconds < 60 {
246		return fmt.Sprintf("%.1fs", diffSeconds)
247	}
248
249	minutes := int(diffSeconds / 60)
250	seconds := int(diffSeconds) % 60
251	return fmt.Sprintf("%dm%ds", minutes, seconds)
252}
253
254func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
255	for _, v := range m.messages {
256		for _, c := range v.ToolResults() {
257			if c.ToolCallID == callID {
258				return &c
259			}
260		}
261	}
262	return nil
263}
264
265func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
266	key := ""
267	value := ""
268	result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
269
270	response := m.findToolResponse(toolCall.ID)
271	if response != nil && response.IsError {
272		// Clean up error message for display by removing newlines
273		// This ensures error messages display properly in the UI
274		errMsg := strings.ReplaceAll(response.Content, "\n", " ")
275		result = styles.BaseStyle.Foreground(styles.Error).Render(ansi.Truncate(errMsg, 40, "..."))
276	} else if response != nil {
277		result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render("Done")
278	}
279	switch toolCall.Name {
280	// TODO: add result data to the tools
281	case agent.AgentToolName:
282		key = "Task"
283		var params agent.AgentParams
284		json.Unmarshal([]byte(toolCall.Input), &params)
285		value = strings.ReplaceAll(params.Prompt, "\n", " ")
286		if response != nil && !response.IsError {
287			firstRow := strings.ReplaceAll(response.Content, "\n", " ")
288			result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(ansi.Truncate(firstRow, 40, "..."))
289		}
290	case tools.BashToolName:
291		key = "Bash"
292		var params tools.BashParams
293		json.Unmarshal([]byte(toolCall.Input), &params)
294		value = params.Command
295		if response != nil && !response.IsError {
296			metadata := tools.BashResponseMetadata{}
297			json.Unmarshal([]byte(response.Metadata), &metadata)
298			result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("Took %s", formatTimeDifference(metadata.StartTime, metadata.EndTime)))
299		}
300
301	case tools.EditToolName:
302		key = "Edit"
303		var params tools.EditParams
304		json.Unmarshal([]byte(toolCall.Input), &params)
305		value = params.FilePath
306		if response != nil && !response.IsError {
307			metadata := tools.EditResponseMetadata{}
308			json.Unmarshal([]byte(response.Metadata), &metadata)
309			result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
310		}
311	case tools.FetchToolName:
312		key = "Fetch"
313		var params tools.FetchParams
314		json.Unmarshal([]byte(toolCall.Input), &params)
315		value = params.URL
316		if response != nil && !response.IsError {
317			result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
318		}
319	case tools.GlobToolName:
320		key = "Glob"
321		var params tools.GlobParams
322		json.Unmarshal([]byte(toolCall.Input), &params)
323		if params.Path == "" {
324			params.Path = "."
325		}
326		value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
327		if response != nil && !response.IsError {
328			metadata := tools.GlobResponseMetadata{}
329			json.Unmarshal([]byte(response.Metadata), &metadata)
330			if metadata.Truncated {
331				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
332			} else {
333				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
334			}
335		}
336	case tools.GrepToolName:
337		key = "Grep"
338		var params tools.GrepParams
339		json.Unmarshal([]byte(toolCall.Input), &params)
340		if params.Path == "" {
341			params.Path = "."
342		}
343		value = fmt.Sprintf("%s (%s)", params.Pattern, params.Path)
344		if response != nil && !response.IsError {
345			metadata := tools.GrepResponseMetadata{}
346			json.Unmarshal([]byte(response.Metadata), &metadata)
347			if metadata.Truncated {
348				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfMatches))
349			} else {
350				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
351			}
352		}
353	case tools.LSToolName:
354		key = "ls"
355		var params tools.LSParams
356		json.Unmarshal([]byte(toolCall.Input), &params)
357		if params.Path == "" {
358			params.Path = "."
359		}
360		value = params.Path
361		if response != nil && !response.IsError {
362			metadata := tools.LSResponseMetadata{}
363			json.Unmarshal([]byte(response.Metadata), &metadata)
364			if metadata.Truncated {
365				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found (truncated)", metadata.NumberOfFiles))
366			} else {
367				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
368			}
369		}
370	case tools.SourcegraphToolName:
371		key = "Sourcegraph"
372		var params tools.SourcegraphParams
373		json.Unmarshal([]byte(toolCall.Input), &params)
374		value = params.Query
375		if response != nil && !response.IsError {
376			metadata := tools.SourcegraphResponseMetadata{}
377			json.Unmarshal([]byte(response.Metadata), &metadata)
378			if metadata.Truncated {
379				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found (truncated)", metadata.NumberOfMatches))
380			} else {
381				result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
382			}
383		}
384	case tools.ViewToolName:
385		key = "View"
386		var params tools.ViewParams
387		json.Unmarshal([]byte(toolCall.Input), &params)
388		value = params.FilePath
389	case tools.WriteToolName:
390		key = "Write"
391		var params tools.WriteParams
392		json.Unmarshal([]byte(toolCall.Input), &params)
393		value = params.FilePath
394		if response != nil && !response.IsError {
395			metadata := tools.WriteResponseMetadata{}
396			json.Unmarshal([]byte(response.Metadata), &metadata)
397
398			result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
399		}
400	default:
401		key = toolCall.Name
402		var params map[string]any
403		json.Unmarshal([]byte(toolCall.Input), &params)
404		jsonData, _ := json.Marshal(params)
405		value = string(jsonData)
406	}
407
408	style := styles.BaseStyle.
409		Width(m.width).
410		BorderLeft(true).
411		BorderStyle(lipgloss.ThickBorder()).
412		PaddingLeft(1).
413		BorderForeground(styles.Yellow)
414
415	keyStyle := styles.BaseStyle.
416		Foreground(styles.ForgroundDim)
417	valyeStyle := styles.BaseStyle.
418		Foreground(styles.Forground)
419
420	if isNested {
421		valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
422	}
423	keyValye := keyStyle.Render(
424		fmt.Sprintf("%s: ", key),
425	)
426	if !isNested {
427		value = valyeStyle.
428			Render(
429				ansi.Truncate(
430					value+" ",
431					m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
432					"...",
433				),
434			)
435		value += result
436
437	} else {
438		keyValye = keyStyle.Render(
439			fmt.Sprintf(" └ %s: ", key),
440		)
441		value = valyeStyle.
442			Width(m.width - lipgloss.Width(keyValye) - 2).
443			Render(
444				ansi.Truncate(
445					value,
446					m.width-lipgloss.Width(keyValye)-2,
447					"...",
448				),
449			)
450	}
451
452	innerToolCalls := make([]string, 0)
453	if toolCall.Name == agent.AgentToolName {
454		messages, _ := m.app.Messages.List(context.Background(), toolCall.ID)
455		toolCalls := make([]message.ToolCall, 0)
456		for _, v := range messages {
457			toolCalls = append(toolCalls, v.ToolCalls()...)
458		}
459		for _, v := range toolCalls {
460			call := m.renderToolCall(v, true)
461			innerToolCalls = append(innerToolCalls, call)
462		}
463	}
464
465	if isNested {
466		return lipgloss.JoinHorizontal(
467			lipgloss.Left,
468			keyValye,
469			value,
470		)
471	}
472	callContent := lipgloss.JoinHorizontal(
473		lipgloss.Left,
474		keyValye,
475		value,
476	)
477	callContent = strings.ReplaceAll(callContent, "\n", "")
478	if len(innerToolCalls) > 0 {
479		callContent = lipgloss.JoinVertical(
480			lipgloss.Left,
481			callContent,
482			lipgloss.JoinVertical(
483				lipgloss.Left,
484				innerToolCalls...,
485			),
486		)
487	}
488	return style.Render(callContent)
489}
490
491func (m *messagesCmp) renderAssistantMessage(msg message.Message) []uiMessage {
492	// find the user message that is before this assistant message
493	var userMsg message.Message
494	for i := len(m.messages) - 1; i >= 0; i-- {
495		if m.messages[i].Role == message.User {
496			userMsg = m.messages[i]
497			break
498		}
499	}
500	messages := make([]uiMessage, 0)
501	if msg.Content().String() != "" {
502		info := make([]string, 0)
503		if msg.IsFinished() && msg.FinishReason() == "end_turn" {
504			finish := msg.FinishPart()
505			took := formatTimeDifference(userMsg.CreatedAt, finish.Time)
506
507			info = append(info, styles.BaseStyle.Width(m.width-1).Foreground(styles.ForgroundDim).Render(
508				fmt.Sprintf(" %s (%s)", models.SupportedModels[msg.Model].Name, took),
509			))
510		}
511		content := m.renderSimpleMessage(msg, info...)
512		messages = append(messages, uiMessage{
513			messageType: assistantMessageType,
514			position:    0, // gets updated in renderView
515			height:      lipgloss.Height(content),
516			content:     content,
517		})
518	}
519	for _, v := range msg.ToolCalls() {
520		content := m.renderToolCall(v, false)
521		messages = append(messages,
522			uiMessage{
523				messageType: toolMessageType,
524				position:    0, // gets updated in renderView
525				height:      lipgloss.Height(content),
526				content:     content,
527			},
528		)
529	}
530
531	return messages
532}
533
534func (m *messagesCmp) renderView() {
535	m.uiMessages = make([]uiMessage, 0)
536	pos := 0
537
538	// If we have messages, ensure the last message is not cached
539	// This ensures we always render the latest content for the most recent message
540	// which may be actively updating (e.g., during generation)
541	if len(m.messages) > 0 {
542		lastMsgID := m.messages[len(m.messages)-1].ID
543		delete(m.cachedContent, lastMsgID)
544	}
545
546	// Limit cache to 10 messages
547	if len(m.cachedContent) > 15 {
548		// Create a list of keys to delete (oldest messages first)
549		keys := make([]string, 0, len(m.cachedContent))
550		for k := range m.cachedContent {
551			keys = append(keys, k)
552		}
553		// Delete oldest messages until we have 10 or fewer
554		for i := 0; i < len(keys)-15; i++ {
555			delete(m.cachedContent, keys[i])
556		}
557	}
558
559	for _, v := range m.messages {
560		switch v.Role {
561		case message.User:
562			content := m.renderSimpleMessage(v)
563			m.uiMessages = append(m.uiMessages, uiMessage{
564				messageType: userMessageType,
565				position:    pos,
566				height:      lipgloss.Height(content),
567				content:     content,
568			})
569			pos += lipgloss.Height(content) + 1 // + 1 for spacing
570		case message.Assistant:
571			assistantMessages := m.renderAssistantMessage(v)
572			for _, msg := range assistantMessages {
573				msg.position = pos
574				m.uiMessages = append(m.uiMessages, msg)
575				pos += msg.height + 1 // + 1 for spacing
576			}
577
578		}
579	}
580
581	messages := make([]string, 0)
582	for _, v := range m.uiMessages {
583		messages = append(messages, v.content,
584			styles.BaseStyle.
585				Width(m.width).
586				Render(
587					"",
588				),
589		)
590	}
591	m.viewport.SetContent(
592		styles.BaseStyle.
593			Width(m.width).
594			Render(
595				lipgloss.JoinVertical(
596					lipgloss.Top,
597					messages...,
598				),
599			),
600	)
601}
602
603func (m *messagesCmp) View() string {
604	if len(m.messages) == 0 {
605		content := styles.BaseStyle.
606			Width(m.width).
607			Height(m.height - 1).
608			Render(
609				m.initialScreen(),
610			)
611
612		return styles.BaseStyle.
613			Width(m.width).
614			Render(
615				lipgloss.JoinVertical(
616					lipgloss.Top,
617					content,
618					m.help(),
619				),
620			)
621	}
622
623	return styles.BaseStyle.
624		Width(m.width).
625		Render(
626			lipgloss.JoinVertical(
627				lipgloss.Top,
628				m.viewport.View(),
629				m.help(),
630			),
631		)
632}
633
634func (m *messagesCmp) help() string {
635	text := ""
636
637	if m.IsAgentWorking() {
638		text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
639			fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
640		)
641	}
642	if m.writingMode {
643		text += lipgloss.JoinHorizontal(
644			lipgloss.Left,
645			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
646			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
647			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
648		)
649	} else {
650		text += lipgloss.JoinHorizontal(
651			lipgloss.Left,
652			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
653			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
654			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
655		)
656	}
657
658	return styles.BaseStyle.
659		Width(m.width).
660		Render(text)
661}
662
663func (m *messagesCmp) initialScreen() string {
664	return styles.BaseStyle.Width(m.width).Render(
665		lipgloss.JoinVertical(
666			lipgloss.Top,
667			header(m.width),
668			"",
669			lspsConfigured(m.width),
670		),
671	)
672}
673
674func (m *messagesCmp) SetSize(width, height int) {
675	m.width = width
676	m.height = height
677	m.viewport.Width = width
678	m.viewport.Height = height - 1
679	focusRenderer, _ := glamour.NewTermRenderer(
680		glamour.WithStyles(styles.MarkdownTheme(true)),
681		glamour.WithWordWrap(width-1),
682	)
683	renderer, _ := glamour.NewTermRenderer(
684		glamour.WithStyles(styles.MarkdownTheme(false)),
685		glamour.WithWordWrap(width-1),
686	)
687	m.focusRenderer = focusRenderer
688	// clear the cached content
689	for k := range m.cachedContent {
690		delete(m.cachedContent, k)
691	}
692	m.renderer = renderer
693	if len(m.messages) > 0 {
694		m.renderView()
695		m.viewport.GotoBottom()
696	}
697}
698
699func (m *messagesCmp) GetSize() (int, int) {
700	return m.width, m.height
701}
702
703func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
704	m.session = session
705	messages, err := m.app.Messages.List(context.Background(), session.ID)
706	if err != nil {
707		return util.ReportError(err)
708	}
709	m.messages = messages
710	m.currentMsgID = m.messages[len(m.messages)-1].ID
711	m.needsRerender = true
712	m.cachedContent = make(map[string]string)
713	return nil
714}
715
716func (m *messagesCmp) BindingKeys() []key.Binding {
717	bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
718	return bindings
719}
720
721func NewMessagesCmp(app *app.App) tea.Model {
722	focusRenderer, _ := glamour.NewTermRenderer(
723		glamour.WithStyles(styles.MarkdownTheme(true)),
724		glamour.WithWordWrap(80),
725	)
726	renderer, _ := glamour.NewTermRenderer(
727		glamour.WithStyles(styles.MarkdownTheme(false)),
728		glamour.WithWordWrap(80),
729	)
730
731	s := spinner.New()
732	s.Spinner = spinner.Pulse
733	return &messagesCmp{
734		app:           app,
735		writingMode:   true,
736		cachedContent: make(map[string]string),
737		viewport:      viewport.New(0, 0),
738		focusRenderer: focusRenderer,
739		renderer:      renderer,
740		spinner:       s,
741	}
742}