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