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[message.Message]:
103		needsRerender := false
104		if msg.Type == pubsub.CreatedEvent {
105			if msg.Payload.SessionID == m.session.ID {
106
107				messageExists := false
108				for _, v := range m.messages {
109					if v.ID == msg.Payload.ID {
110						messageExists = true
111						break
112					}
113				}
114
115				if !messageExists {
116					if len(m.messages) > 0 {
117						lastMsgID := m.messages[len(m.messages)-1].ID
118						delete(m.cachedContent, lastMsgID)
119					}
120
121					m.messages = append(m.messages, msg.Payload)
122					delete(m.cachedContent, m.currentMsgID)
123					m.currentMsgID = msg.Payload.ID
124					needsRerender = true
125				}
126			}
127			// There are tool calls from the child task
128			for _, v := range m.messages {
129				for _, c := range v.ToolCalls() {
130					if c.ID == msg.Payload.SessionID {
131						delete(m.cachedContent, v.ID)
132						needsRerender = true
133					}
134				}
135			}
136		} else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID {
137			for i, v := range m.messages {
138				if v.ID == msg.Payload.ID {
139					m.messages[i] = msg.Payload
140					delete(m.cachedContent, msg.Payload.ID)
141					needsRerender = true
142					break
143				}
144			}
145		}
146		if needsRerender {
147			m.renderView()
148			if len(m.messages) > 0 {
149				if (msg.Type == pubsub.CreatedEvent) ||
150					(msg.Type == pubsub.UpdatedEvent && msg.Payload.ID == m.messages[len(m.messages)-1].ID) {
151					m.viewport.GotoBottom()
152				}
153			}
154		}
155	}
156
157	spinner, cmd := m.spinner.Update(msg)
158	m.spinner = spinner
159	cmds = append(cmds, cmd)
160	return m, tea.Batch(cmds...)
161}
162
163func (m *messagesCmp) IsAgentWorking() bool {
164	return m.app.CoderAgent.IsSessionBusy(m.session.ID)
165}
166
167func formatTimeDifference(unixTime1, unixTime2 int64) string {
168	diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1)))
169
170	if diffSeconds < 60 {
171		return fmt.Sprintf("%.1fs", diffSeconds)
172	}
173
174	minutes := int(diffSeconds / 60)
175	seconds := int(diffSeconds) % 60
176	return fmt.Sprintf("%dm%ds", minutes, seconds)
177}
178
179func (m *messagesCmp) renderView() {
180	m.uiMessages = make([]uiMessage, 0)
181	pos := 0
182	baseStyle := styles.BaseStyle()
183
184	if m.width == 0 {
185		return
186	}
187	for inx, msg := range m.messages {
188		switch msg.Role {
189		case message.User:
190			if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
191				m.uiMessages = append(m.uiMessages, cache.content...)
192				continue
193			}
194			userMsg := renderUserMessage(
195				msg,
196				msg.ID == m.currentMsgID,
197				m.width,
198				pos,
199			)
200			m.uiMessages = append(m.uiMessages, userMsg)
201			m.cachedContent[msg.ID] = cacheItem{
202				width:   m.width,
203				content: []uiMessage{userMsg},
204			}
205			pos += userMsg.height + 1 // + 1 for spacing
206		case message.Assistant:
207			if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width {
208				m.uiMessages = append(m.uiMessages, cache.content...)
209				continue
210			}
211			assistantMessages := renderAssistantMessage(
212				msg,
213				inx,
214				m.messages,
215				m.app.Messages,
216				m.currentMsgID,
217				m.width,
218				pos,
219			)
220			for _, msg := range assistantMessages {
221				m.uiMessages = append(m.uiMessages, msg)
222				pos += msg.height + 1 // + 1 for spacing
223			}
224			m.cachedContent[msg.ID] = cacheItem{
225				width:   m.width,
226				content: assistantMessages,
227			}
228		}
229	}
230
231	messages := make([]string, 0)
232	for _, v := range m.uiMessages {
233		messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content),
234			baseStyle.
235				Width(m.width).
236				Render(
237					"",
238				),
239		)
240	}
241
242	m.viewport.SetContent(
243		baseStyle.
244			Width(m.width).
245			Render(
246				lipgloss.JoinVertical(
247					lipgloss.Top,
248					messages...,
249				),
250			),
251	)
252}
253
254func (m *messagesCmp) View() string {
255	baseStyle := styles.BaseStyle()
256
257	if m.rendering {
258		return baseStyle.
259			Width(m.width).
260			Render(
261				lipgloss.JoinVertical(
262					lipgloss.Top,
263					"Loading...",
264					m.working(),
265					m.help(),
266				),
267			)
268	}
269	if len(m.messages) == 0 {
270		content := baseStyle.
271			Width(m.width).
272			Height(m.height - 1).
273			Render(
274				m.initialScreen(),
275			)
276
277		return baseStyle.
278			Width(m.width).
279			Render(
280				lipgloss.JoinVertical(
281					lipgloss.Top,
282					content,
283					"",
284					m.help(),
285				),
286			)
287	}
288
289	return baseStyle.
290		Width(m.width).
291		Render(
292			lipgloss.JoinVertical(
293				lipgloss.Top,
294				m.viewport.View(),
295				m.working(),
296				m.help(),
297			),
298		)
299}
300
301func hasToolsWithoutResponse(messages []message.Message) bool {
302	toolCalls := make([]message.ToolCall, 0)
303	toolResults := make([]message.ToolResult, 0)
304	for _, m := range messages {
305		toolCalls = append(toolCalls, m.ToolCalls()...)
306		toolResults = append(toolResults, m.ToolResults()...)
307	}
308
309	for _, v := range toolCalls {
310		found := false
311		for _, r := range toolResults {
312			if v.ID == r.ToolCallID {
313				found = true
314				break
315			}
316		}
317		if !found && v.Finished {
318			return true
319		}
320	}
321	return false
322}
323
324func hasUnfinishedToolCalls(messages []message.Message) bool {
325	toolCalls := make([]message.ToolCall, 0)
326	for _, m := range messages {
327		toolCalls = append(toolCalls, m.ToolCalls()...)
328	}
329	for _, v := range toolCalls {
330		if !v.Finished {
331			return true
332		}
333	}
334	return false
335}
336
337func (m *messagesCmp) working() string {
338	text := ""
339	if m.IsAgentWorking() && len(m.messages) > 0 {
340		t := theme.CurrentTheme()
341		baseStyle := styles.BaseStyle()
342
343		task := "Thinking..."
344		lastMessage := m.messages[len(m.messages)-1]
345		if hasToolsWithoutResponse(m.messages) {
346			task = "Waiting for tool response..."
347		} else if hasUnfinishedToolCalls(m.messages) {
348			task = "Building tool call..."
349		} else if !lastMessage.IsFinished() {
350			task = "Generating..."
351		}
352		if task != "" {
353			text += baseStyle.
354				Width(m.width).
355				Foreground(t.Primary()).
356				Bold(true).
357				Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
358		}
359	}
360	return text
361}
362
363func (m *messagesCmp) help() string {
364	t := theme.CurrentTheme()
365	baseStyle := styles.BaseStyle()
366
367	text := ""
368
369	if m.app.CoderAgent.IsBusy() {
370		text += lipgloss.JoinHorizontal(
371			lipgloss.Left,
372			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
373			baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
374			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to exit cancel"),
375		)
376	} else {
377		text += lipgloss.JoinHorizontal(
378			lipgloss.Left,
379			baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
380			baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
381			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send the message,"),
382			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" write"),
383			baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
384			baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" and enter to add a new line"),
385		)
386	}
387	return baseStyle.
388		Width(m.width).
389		Render(text)
390}
391
392func (m *messagesCmp) initialScreen() string {
393	baseStyle := styles.BaseStyle()
394
395	return baseStyle.Width(m.width).Render(
396		lipgloss.JoinVertical(
397			lipgloss.Top,
398			header(m.width),
399			"",
400			lspsConfigured(m.width),
401		),
402	)
403}
404
405func (m *messagesCmp) rerender() {
406	for _, msg := range m.messages {
407		delete(m.cachedContent, msg.ID)
408	}
409	m.renderView()
410}
411
412func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
413	if m.width == width && m.height == height {
414		return nil
415	}
416	m.width = width
417	m.height = height
418	m.viewport.Width = width
419	m.viewport.Height = height - 2
420	m.attachments.Width = width + 40
421	m.attachments.Height = 3
422	m.rerender()
423	return nil
424}
425
426func (m *messagesCmp) GetSize() (int, int) {
427	return m.width, m.height
428}
429
430func (m *messagesCmp) SetSession(session session.Session) tea.Cmd {
431	if m.session.ID == session.ID {
432		return nil
433	}
434	m.session = session
435	messages, err := m.app.Messages.List(context.Background(), session.ID)
436	if err != nil {
437		return util.ReportError(err)
438	}
439	m.messages = messages
440	if len(m.messages) > 0 {
441		m.currentMsgID = m.messages[len(m.messages)-1].ID
442	}
443	delete(m.cachedContent, m.currentMsgID)
444	m.rendering = true
445	return func() tea.Msg {
446		m.renderView()
447		return renderFinishedMsg{}
448	}
449}
450
451func (m *messagesCmp) BindingKeys() []key.Binding {
452	return []key.Binding{
453		m.viewport.KeyMap.PageDown,
454		m.viewport.KeyMap.PageUp,
455		m.viewport.KeyMap.HalfPageUp,
456		m.viewport.KeyMap.HalfPageDown,
457	}
458}
459
460func NewMessagesCmp(app *app.App) tea.Model {
461	s := spinner.New()
462	s.Spinner = spinner.Pulse
463	vp := viewport.New(0, 0)
464	attachmets := viewport.New(0, 0)
465	vp.KeyMap.PageUp = messageKeys.PageUp
466	vp.KeyMap.PageDown = messageKeys.PageDown
467	vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
468	vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
469	return &messagesCmp{
470		app:           app,
471		cachedContent: make(map[string]cacheItem),
472		viewport:      vp,
473		spinner:       s,
474		attachments:   attachmets,
475	}
476}