list.go

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