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