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