list.go

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