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