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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
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), ¶ms)
388 value = params.FilePath
389 case tools.WriteToolName:
390 key = "Write"
391 var params tools.WriteParams
392 json.Unmarshal([]byte(toolCall.Input), ¶ms)
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), ¶ms)
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}