@@ -1,742 +0,0 @@
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"
31−type uiMessageType int
34− userMessageType uiMessageType = iota
39−// messagesTickMsg is a message sent by the timer to refresh messages
40−type messagesTickMsg time.Time
42−type uiMessage struct {
44− messageType uiMessageType
50−type messagesCmp struct {
54− viewport viewport.Model
55− session session.Session
56− messages []message.Message
57− uiMessages []uiMessage
59− renderer *glamour.TermRenderer
60− focusRenderer *glamour.TermRenderer
61− cachedContent map[string]string
62− spinner spinner.Model
66−func (m *messagesCmp) Init() tea.Cmd {
67− return tea.Batch(m.viewport.Init(), m.spinner.Tick, m.tickMessages())
70−func (m *messagesCmp) tickMessages() tea.Cmd {
71− return tea.Tick(time.Second, func(t time.Time) tea.Msg {
72− return messagesTickMsg(t)
76−func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, 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)
84− m.messages = messages
85− m.needsRerender = true
89− cmds = append(cmds, m.tickMessages())
91− m.writingMode = bool(msg)
92− case SessionSelectedMsg:
93− if msg.ID != m.session.ID {
94− cmd := m.SetSession(msg)
95− m.needsRerender = true
99− case SessionClearedMsg:
100− m.session = session.Session{}
101− m.messages = make([]message.Message, 0)
103− m.needsRerender = true
104− m.cachedContent = make(map[string]string)
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
116− messageExists := false
117− for _, v := range m.messages {
118− if v.ID == msg.Payload.ID {
119− messageExists = true
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)
131− m.messages = append(m.messages, msg.Payload)
132− delete(m.cachedContent, m.currentMsgID)
133− m.currentMsgID = msg.Payload.ID
134− m.needsRerender = true
137− for _, v := range m.messages {
138− for _, c := range v.ToolCalls() {
139− if c.ID == msg.Payload.SessionID {
140− m.needsRerender = true
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)
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)
156− m.needsRerender = true
163− oldPos := m.viewport.YPosition
164− u, cmd := m.viewport.Update(msg)
166− m.needsRerender = m.needsRerender || m.viewport.YPosition != oldPos
167− cmds = append(cmds, cmd)
169− spinner, cmd := m.spinner.Update(msg)
171− cmds = append(cmds, cmd)
173− if m.needsRerender {
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()
183− m.needsRerender = false
185− return m, tea.Batch(cmds...)
188−func (m *messagesCmp) IsAgentWorking() bool {
189− return m.app.CoderAgent.IsSessionBusy(m.session.ID)
192−func (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
196− // Only use cache for non-last messages
198− if v, ok := m.cachedContent[msg.ID]; ok {
203− style := styles.BaseStyle.
206− Foreground(styles.ForgroundDim).
207− BorderForeground(styles.ForgroundDim).
208− BorderStyle(lipgloss.ThickBorder())
210− renderer := m.renderer
211− if msg.ID == m.currentMsgID {
213− Foreground(styles.Forground).
214− BorderForeground(styles.Blue).
215− BorderStyle(lipgloss.ThickBorder())
216− renderer = m.focusRenderer
218− c, _ := renderer.Render(msg.Content().String())
220− styles.ForceReplaceBackgroundWithLipgloss(c, styles.Background),
222− // remove newline at the end
223− parts[0] = strings.TrimSuffix(parts[0], "\n")
225− parts = append(parts, info...)
227− rendered := style.Render(
228− lipgloss.JoinVertical(
234− // Only cache if it's not the last message
236− m.cachedContent[msg.ID] = rendered
242−func formatTimeDifference(unixTime1, unixTime2 int64) string {
243− diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
245− if diffSeconds < 60 {
246− return fmt.Sprintf("%.1fs", diffSeconds)
249− minutes := int(diffSeconds / 60)
250− seconds := int(diffSeconds) % 60
251− return fmt.Sprintf("%dm%ds", minutes, seconds)
254−func (m *messagesCmp) findToolResponse(callID string) *message.ToolResult {
255− for _, v := range m.messages {
256− for _, c := range v.ToolResults() {
257− if c.ToolCallID == callID {
265−func (m *messagesCmp) renderToolCall(toolCall message.ToolCall, isNested bool) string {
268− result := styles.BaseStyle.Foreground(styles.PrimaryColor).Render(m.spinner.View() + " waiting for response...")
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")
279− switch toolCall.Name {
280− // TODO: add result data to the tools
281− case agent.AgentToolName:
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, "..."))
290− case tools.BashToolName:
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)))
301− case tools.EditToolName:
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))
311− case tools.FetchToolName:
313− var params tools.FetchParams
314− json.Unmarshal([]byte(toolCall.Input), ¶ms)
316− if response != nil && !response.IsError {
317− result = styles.BaseStyle.Foreground(styles.Error).Render(response.Content)
319− case tools.GlobToolName:
321− var params tools.GlobParams
322− json.Unmarshal([]byte(toolCall.Input), ¶ms)
323− if params.Path == "" {
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))
333− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
336− case tools.GrepToolName:
338− var params tools.GrepParams
339− json.Unmarshal([]byte(toolCall.Input), ¶ms)
340− if params.Path == "" {
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))
350− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfMatches))
353− case tools.LSToolName:
355− var params tools.LSParams
356− json.Unmarshal([]byte(toolCall.Input), ¶ms)
357− if 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))
367− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d files found", metadata.NumberOfFiles))
370− case tools.SourcegraphToolName:
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))
381− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d matches found", metadata.NumberOfMatches))
384− case tools.ViewToolName:
386− var params tools.ViewParams
387− json.Unmarshal([]byte(toolCall.Input), ¶ms)
388− value = params.FilePath
389− case tools.WriteToolName:
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)
398− result = styles.BaseStyle.Foreground(styles.ForgroundMid).Render(fmt.Sprintf("%d Additions %d Removals", metadata.Additions, metadata.Removals))
402− var params map[string]any
403− json.Unmarshal([]byte(toolCall.Input), ¶ms)
404− jsonData, _ := json.Marshal(params)
405− value = string(jsonData)
408− style := styles.BaseStyle.
411− BorderStyle(lipgloss.ThickBorder()).
413− BorderForeground(styles.Yellow)
415− keyStyle := styles.BaseStyle.
416− Foreground(styles.ForgroundDim)
417− valyeStyle := styles.BaseStyle.
418− Foreground(styles.Forground)
421− valyeStyle = valyeStyle.Foreground(styles.ForgroundMid)
423− keyValye := keyStyle.Render(
424− fmt.Sprintf("%s: ", key),
431− m.width-lipgloss.Width(keyValye)-2-lipgloss.Width(result),
438− keyValye = keyStyle.Render(
439− fmt.Sprintf(" └ %s: ", key),
442− Width(m.width - lipgloss.Width(keyValye) - 2).
446− m.width-lipgloss.Width(keyValye)-2,
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()...)
459− for _, v := range toolCalls {
460− call := m.renderToolCall(v, true)
461− innerToolCalls = append(innerToolCalls, call)
466− return lipgloss.JoinHorizontal(
472− callContent := lipgloss.JoinHorizontal(
477− callContent = strings.ReplaceAll(callContent, "\n", "")
478− if len(innerToolCalls) > 0 {
479− callContent = lipgloss.JoinVertical(
482− lipgloss.JoinVertical(
488− return style.Render(callContent)
491−func (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]
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)
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),
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),
519− for _, v := range msg.ToolCalls() {
520− content := m.renderToolCall(v, false)
521− messages = append(messages,
523− messageType: toolMessageType,
524− position: 0, // gets updated in renderView
525− height: lipgloss.Height(content),
534−func (m *messagesCmp) renderView() {
535− m.uiMessages = make([]uiMessage, 0)
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)
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)
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])
559− for _, v := range m.messages {
562− content := m.renderSimpleMessage(v)
563− m.uiMessages = append(m.uiMessages, uiMessage{
564− messageType: userMessageType,
566− height: lipgloss.Height(content),
569− pos += lipgloss.Height(content) + 1 // + 1 for spacing
570− case message.Assistant:
571− assistantMessages := m.renderAssistantMessage(v)
572− for _, msg := range assistantMessages {
574− m.uiMessages = append(m.uiMessages, msg)
575− pos += msg.height + 1 // + 1 for spacing
581− messages := make([]string, 0)
582− for _, v := range m.uiMessages {
583− messages = append(messages, v.content,
591− m.viewport.SetContent(
595− lipgloss.JoinVertical(
603−func (m *messagesCmp) View() string {
604− if len(m.messages) == 0 {
605− content := styles.BaseStyle.
607− Height(m.height - 1).
612− return styles.BaseStyle.
615− lipgloss.JoinVertical(
623− return styles.BaseStyle.
626− lipgloss.JoinVertical(
634−func (m *messagesCmp) help() string {
637− if m.IsAgentWorking() {
638− text += styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render(
639− fmt.Sprintf("%s %s ", m.spinner.View(), "Generating..."),
643− text += lipgloss.JoinHorizontal(
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"),
650− text += lipgloss.JoinHorizontal(
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"),
658− return styles.BaseStyle.
663−func (m *messagesCmp) initialScreen() string {
664− return styles.BaseStyle.Width(m.width).Render(
665− lipgloss.JoinVertical(
669− lspsConfigured(m.width),
674−func (m *messagesCmp) SetSize(width, height int) {
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),
683− renderer, _ := glamour.NewTermRenderer(
684− glamour.WithStyles(styles.MarkdownTheme(false)),
685− glamour.WithWordWrap(width-1),
687− m.focusRenderer = focusRenderer
688− // clear the cached content
689− for k := range m.cachedContent {
690− delete(m.cachedContent, k)
692− m.renderer = renderer
693− if len(m.messages) > 0 {
695− m.viewport.GotoBottom()
699−func (m *messagesCmp) GetSize() (int, int) {
700− return m.width, m.height
703−func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
705− messages, err := m.app.Messages.List(context.Background(), session.ID)
707− return util.ReportError(err)
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)
716−func (m *messagesCmp) BindingKeys() []key.Binding {
717− bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
721−func NewMessagesCmp(app *app.App) tea.Model {
722− focusRenderer, _ := glamour.NewTermRenderer(
723− glamour.WithStyles(styles.MarkdownTheme(true)),
724− glamour.WithWordWrap(80),
726− renderer, _ := glamour.NewTermRenderer(
727− glamour.WithStyles(styles.MarkdownTheme(false)),
728− glamour.WithWordWrap(80),
732− s.Spinner = spinner.Pulse
733− return &messagesCmp{
736− cachedContent: make(map[string]string),
737− viewport: viewport.New(0, 0),
738− focusRenderer: focusRenderer,