list.go

  1package chat
  2
  3import (
  4	"context"
  5	"fmt"
  6	"math"
  7	"sync"
  8	"time"
  9
 10	"github.com/charmbracelet/bubbles/key"
 11	"github.com/charmbracelet/bubbles/spinner"
 12	"github.com/charmbracelet/bubbles/viewport"
 13	tea "github.com/charmbracelet/bubbletea"
 14	"github.com/charmbracelet/lipgloss"
 15	"github.com/kujtimiihoxha/opencode/internal/app"
 16	"github.com/kujtimiihoxha/opencode/internal/logging"
 17	"github.com/kujtimiihoxha/opencode/internal/message"
 18	"github.com/kujtimiihoxha/opencode/internal/pubsub"
 19	"github.com/kujtimiihoxha/opencode/internal/session"
 20	"github.com/kujtimiihoxha/opencode/internal/tui/layout"
 21	"github.com/kujtimiihoxha/opencode/internal/tui/styles"
 22	"github.com/kujtimiihoxha/opencode/internal/tui/util"
 23)
 24
 25type messagesCmp struct {
 26	app           *app.App
 27	width, height int
 28	writingMode   bool
 29	viewport      viewport.Model
 30	session       session.Session
 31	messages      []message.Message
 32	uiMessages    []uiMessage
 33	currentMsgID  string
 34	mutex         sync.Mutex
 35	cachedContent map[string][]uiMessage
 36	spinner       spinner.Model
 37	rendering     bool
 38}
 39type renderFinishedMsg struct{}
 40
 41func (m *messagesCmp) Init() tea.Cmd {
 42	return tea.Batch(m.viewport.Init())
 43}
 44
 45func (m *messagesCmp) preloadSessions() tea.Cmd {
 46	return func() tea.Msg {
 47		sessions, err := m.app.Sessions.List(context.Background())
 48		if err != nil {
 49			return util.ReportError(err)()
 50		}
 51		if len(sessions) == 0 {
 52			return nil
 53		}
 54		if len(sessions) > 20 {
 55			sessions = sessions[:20]
 56		}
 57		for _, s := range sessions {
 58			messages, err := m.app.Messages.List(context.Background(), s.ID)
 59			if err != nil {
 60				return util.ReportError(err)()
 61			}
 62			if len(messages) == 0 {
 63				continue
 64			}
 65			m.cacheSessionMessages(messages, m.width)
 66
 67		}
 68		logging.Debug("preloaded sessions")
 69
 70		return nil
 71	}
 72}
 73
 74func (m *messagesCmp) cacheSessionMessages(messages []message.Message, width int) {
 75	m.mutex.Lock()
 76	defer m.mutex.Unlock()
 77	pos := 0
 78	if m.width == 0 {
 79		return
 80	}
 81	for inx, msg := range messages {
 82		switch msg.Role {
 83		case message.User:
 84			userMsg := renderUserMessage(
 85				msg,
 86				false,
 87				width,
 88				pos,
 89			)
 90			m.cachedContent[msg.ID] = []uiMessage{userMsg}
 91			pos += userMsg.height + 1 // + 1 for spacing
 92		case message.Assistant:
 93			assistantMessages := renderAssistantMessage(
 94				msg,
 95				inx,
 96				messages,
 97				m.app.Messages,
 98				"",
 99				width,
100				pos,
101			)
102			for _, msg := range assistantMessages {
103				pos += msg.height + 1 // + 1 for spacing
104			}
105			m.cachedContent[msg.ID] = assistantMessages
106		}
107	}
108}
109
110func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
111	var cmds []tea.Cmd
112	switch msg := msg.(type) {
113	case EditorFocusMsg:
114		m.writingMode = bool(msg)
115	case SessionSelectedMsg:
116		if msg.ID != m.session.ID {
117			cmd := m.SetSession(msg)
118			return m, cmd
119		}
120		return m, nil
121	case SessionClearedMsg:
122		m.session = session.Session{}
123		m.messages = make([]message.Message, 0)
124		m.currentMsgID = ""
125		m.rendering = false
126		return m, nil
127
128	case renderFinishedMsg:
129		m.rendering = false
130		m.viewport.GotoBottom()
131	case tea.KeyMsg:
132		if m.writingMode {
133			return m, nil
134		}
135	case pubsub.Event[message.Message]:
136		needsRerender := false
137		if msg.Type == pubsub.CreatedEvent {
138			if msg.Payload.SessionID == m.session.ID {
139
140				messageExists := false
141				for _, v := range m.messages {
142					if v.ID == msg.Payload.ID {
143						messageExists = true
144						break
145					}
146				}
147
148				if !messageExists {
149					if len(m.messages) > 0 {
150						lastMsgID := m.messages[len(m.messages)-1].ID
151						delete(m.cachedContent, lastMsgID)
152					}
153
154					m.messages = append(m.messages, msg.Payload)
155					delete(m.cachedContent, m.currentMsgID)
156					m.currentMsgID = msg.Payload.ID
157					needsRerender = true
158				}
159			}
160			// There are tool calls from the child task
161			for _, v := range m.messages {
162				for _, c := range v.ToolCalls() {
163					if c.ID == msg.Payload.SessionID {
164						delete(m.cachedContent, v.ID)
165						needsRerender = true
166					}
167				}
168			}
169		} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
170			for i, v := range m.messages {
171				if v.ID == msg.Payload.ID {
172					m.messages[i] = msg.Payload
173					delete(m.cachedContent, msg.Payload.ID)
174					needsRerender = true
175					break
176				}
177			}
178		}
179		if needsRerender {
180			m.renderView()
181			if len(m.messages) > 0 {
182				if (msg.Type == pubsub.CreatedEvent) ||
183					(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
184					m.viewport.GotoBottom()
185				}
186			}
187		}
188	}
189
190	u, cmd := m.viewport.Update(msg)
191	m.viewport = u
192	cmds = append(cmds, cmd)
193
194	spinner, cmd := m.spinner.Update(msg)
195	m.spinner = spinner
196	cmds = append(cmds, cmd)
197	return m, tea.Batch(cmds...)
198}
199
200func (m *messagesCmp) IsAgentWorking() bool {
201	return m.app.CoderAgent.IsSessionBusy(m.session.ID)
202}
203
204func formatTimeDifference(unixTime1, unixTime2 int64) string {
205	diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
206
207	if diffSeconds < 60 {
208		return fmt.Sprintf("%.1fs", diffSeconds)
209	}
210
211	minutes := int(diffSeconds / 60)
212	seconds := int(diffSeconds) % 60
213	return fmt.Sprintf("%dm%ds", minutes, seconds)
214}
215
216func (m *messagesCmp) renderView() {
217	m.uiMessages = make([]uiMessage, 0)
218	pos := 0
219
220	if m.width == 0 {
221		return
222	}
223	for inx, msg := range m.messages {
224		switch msg.Role {
225		case message.User:
226			if messages, ok := m.cachedContent[msg.ID]; ok {
227				m.uiMessages = append(m.uiMessages, messages...)
228				continue
229			}
230			userMsg := renderUserMessage(
231				msg,
232				msg.ID == m.currentMsgID,
233				m.width,
234				pos,
235			)
236			m.uiMessages = append(m.uiMessages, userMsg)
237			m.cachedContent[msg.ID] = []uiMessage{userMsg}
238			pos += userMsg.height + 1 // + 1 for spacing
239		case message.Assistant:
240			if messages, ok := m.cachedContent[msg.ID]; ok {
241				m.uiMessages = append(m.uiMessages, messages...)
242				continue
243			}
244			assistantMessages := renderAssistantMessage(
245				msg,
246				inx,
247				m.messages,
248				m.app.Messages,
249				m.currentMsgID,
250				m.width,
251				pos,
252			)
253			for _, msg := range assistantMessages {
254				m.uiMessages = append(m.uiMessages, msg)
255				pos += msg.height + 1 // + 1 for spacing
256			}
257			m.cachedContent[msg.ID] = assistantMessages
258		}
259	}
260
261	messages := make([]string, 0)
262	for _, v := range m.uiMessages {
263		messages = append(messages, v.content,
264			styles.BaseStyle.
265				Width(m.width).
266				Render(
267					"",
268				),
269		)
270	}
271	m.viewport.SetContent(
272		styles.BaseStyle.
273			Width(m.width).
274			Render(
275				lipgloss.JoinVertical(
276					lipgloss.Top,
277					messages...,
278				),
279			),
280	)
281}
282
283func (m *messagesCmp) View() string {
284	if m.rendering {
285		return styles.BaseStyle.
286			Width(m.width).
287			Render(
288				lipgloss.JoinVertical(
289					lipgloss.Top,
290					"Loading...",
291					m.working(),
292					m.help(),
293				),
294			)
295	}
296	if len(m.messages) == 0 {
297		content := styles.BaseStyle.
298			Width(m.width).
299			Height(m.height - 1).
300			Render(
301				m.initialScreen(),
302			)
303
304		return styles.BaseStyle.
305			Width(m.width).
306			Render(
307				lipgloss.JoinVertical(
308					lipgloss.Top,
309					content,
310					"",
311					m.help(),
312				),
313			)
314	}
315
316	return styles.BaseStyle.
317		Width(m.width).
318		Render(
319			lipgloss.JoinVertical(
320				lipgloss.Top,
321				m.viewport.View(),
322				m.working(),
323				m.help(),
324			),
325		)
326}
327
328func hasToolsWithoutResponse(messages []message.Message) bool {
329	toolCalls := make([]message.ToolCall, 0)
330	toolResults := make([]message.ToolResult, 0)
331	for _, m := range messages {
332		toolCalls = append(toolCalls, m.ToolCalls()...)
333		toolResults = append(toolResults, m.ToolResults()...)
334	}
335
336	for _, v := range toolCalls {
337		found := false
338		for _, r := range toolResults {
339			if v.ID == r.ToolCallID {
340				found = true
341				break
342			}
343		}
344		if !found {
345			return true
346		}
347	}
348
349	return false
350}
351
352func (m *messagesCmp) working() string {
353	text := ""
354	if m.IsAgentWorking() {
355		task := "Thinking..."
356		lastMessage := m.messages[len(m.messages)-1]
357		if hasToolsWithoutResponse(m.messages) {
358			task = "Waiting for tool response..."
359		} else if !lastMessage.IsFinished() {
360			lastUpdate := lastMessage.UpdatedAt
361			currentTime := time.Now().Unix()
362			if lastMessage.Content().String() != "" && lastUpdate != 0 && currentTime-lastUpdate > 5 {
363				task = "Building tool call..."
364			} else if lastMessage.Content().String() == "" {
365				task = "Generating..."
366			}
367			task = ""
368		}
369		if task != "" {
370			text += styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render(
371				fmt.Sprintf("%s %s ", m.spinner.View(), task),
372			)
373		}
374	}
375	return text
376}
377
378func (m *messagesCmp) help() string {
379	text := ""
380
381	if m.writingMode {
382		text += lipgloss.JoinHorizontal(
383			lipgloss.Left,
384			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
385			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"),
386			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"),
387		)
388	} else {
389		text += lipgloss.JoinHorizontal(
390			lipgloss.Left,
391			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "),
392			styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"),
393			styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"),
394		)
395	}
396
397	return styles.BaseStyle.
398		Width(m.width).
399		Render(text)
400}
401
402func (m *messagesCmp) initialScreen() string {
403	return styles.BaseStyle.Width(m.width).Render(
404		lipgloss.JoinVertical(
405			lipgloss.Top,
406			header(m.width),
407			"",
408			lspsConfigured(m.width),
409		),
410	)
411}
412
413func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
414	if m.width == width && m.height == height {
415		return nil
416	}
417	m.width = width
418	m.height = height
419	m.viewport.Width = width
420	m.viewport.Height = height - 2
421	m.renderView()
422	return m.preloadSessions()
423}
424
425func (m *messagesCmp) GetSize() (int, int) {
426	return m.width, m.height
427}
428
429func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
430	if m.session.ID == session.ID {
431		return nil
432	}
433	m.rendering = true
434	return func() tea.Msg {
435		m.session = session
436		messages, err := m.app.Messages.List(context.Background(), session.ID)
437		if err != nil {
438			return util.ReportError(err)
439		}
440		m.messages = messages
441		m.currentMsgID = m.messages[len(m.messages)-1].ID
442		delete(m.cachedContent, m.currentMsgID)
443		m.renderView()
444		return renderFinishedMsg{}
445	}
446}
447
448func (m *messagesCmp) BindingKeys() []key.Binding {
449	bindings := layout.KeyMapToSlice(m.viewport.KeyMap)
450	return bindings
451}
452
453func NewMessagesCmp(app *app.App) tea.Model {
454	s := spinner.New()
455	s.Spinner = spinner.Pulse
456	return &messagesCmp{
457		app:           app,
458		writingMode:   true,
459		cachedContent: make(map[string][]uiMessage),
460		viewport:      viewport.New(0, 0),
461		spinner:       s,
462	}
463}