add anim to the messages

Kujtim Hoxha created

Change summary

internal/tui/components/anim/anim.go              |  18 +-
internal/tui/components/chat/chat.go              |  38 ++--
internal/tui/components/chat/list.go              |  15 -
internal/tui/components/chat/list_v2.go           | 131 ++++++++++++++--
internal/tui/components/chat/messages/messages.go |  43 +++++
internal/tui/components/chat/messages/tool.go     |  20 ++
internal/tui/components/chat/sidebar.go           |   4 
internal/tui/components/core/list/list.go         |  58 ++++++
8 files changed, 257 insertions(+), 70 deletions(-)

Detailed changes

internal/tui/components/anim/anim.go 🔗

@@ -216,16 +216,18 @@ func (a anim) View() string {
 		b.WriteRune(c.currentValue)
 	}
 
-	textStyle := styles.BaseStyle().
-		Foreground(t.Text())
-
-	for _, c := range a.labelChars {
-		b.WriteString(
-			textStyle.Render(string(c.currentValue)),
-		)
+	if len(a.label) > 1 {
+		textStyle := styles.BaseStyle().
+			Foreground(t.Text())
+		for _, c := range a.labelChars {
+			b.WriteString(
+				textStyle.Render(string(c.currentValue)),
+			)
+		}
+		return b.String() + textStyle.Render(a.ellipsis.View())
 	}
 
-	return b.String() + textStyle.Render(a.ellipsis.View())
+	return b.String()
 }
 
 func makeGradientRamp(length int) []color.Color {

internal/tui/components/chat/chat.go 🔗

@@ -5,7 +5,6 @@ import (
 	"sort"
 
 	"github.com/charmbracelet/lipgloss/v2"
-	"github.com/charmbracelet/x/ansi"
 	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/message"
 	"github.com/opencode-ai/opencode/internal/session"
@@ -25,26 +24,24 @@ type SessionClearedMsg struct{}
 
 type EditorFocusMsg bool
 
-func header(width int) string {
+func header() string {
 	return lipgloss.JoinVertical(
 		lipgloss.Top,
-		logo(width),
-		repo(width),
+		logo(),
+		repo(),
 		"",
-		cwd(width),
+		cwd(),
 	)
 }
 
-func lspsConfigured(width int) string {
+func lspsConfigured() string {
 	cfg := config.Get()
 	title := "LSP Configuration"
-	title = ansi.Truncate(title, width, "…")
 
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
 
 	lsps := baseStyle.
-		Width(width).
 		Foreground(t.Primary()).
 		Bold(true).
 		Render(title)
@@ -64,7 +61,6 @@ func lspsConfigured(width int) string {
 			Render(fmt.Sprintf("• %s", name))
 
 		cmd := lsp.Command
-		cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
 
 		lspPath := baseStyle.
 			Foreground(t.TextMuted()).
@@ -72,7 +68,6 @@ func lspsConfigured(width int) string {
 
 		lspViews = append(lspViews,
 			baseStyle.
-				Width(width).
 				Render(
 					lipgloss.JoinHorizontal(
 						lipgloss.Left,
@@ -84,7 +79,6 @@ func lspsConfigured(width int) string {
 	}
 
 	return baseStyle.
-		Width(width).
 		Render(
 			lipgloss.JoinVertical(
 				lipgloss.Left,
@@ -97,7 +91,7 @@ func lspsConfigured(width int) string {
 		)
 }
 
-func logo(width int) string {
+func logo() string {
 	logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()
@@ -108,7 +102,6 @@ func logo(width int) string {
 
 	return baseStyle.
 		Bold(true).
-		Width(width).
 		Render(
 			lipgloss.JoinHorizontal(
 				lipgloss.Left,
@@ -119,22 +112,33 @@ func logo(width int) string {
 		)
 }
 
-func repo(width int) string {
+func repo() string {
 	repo := "https://github.com/opencode-ai/opencode"
 	t := theme.CurrentTheme()
 
 	return styles.BaseStyle().
 		Foreground(t.TextMuted()).
-		Width(width).
 		Render(repo)
 }
 
-func cwd(width int) string {
+func cwd() string {
 	cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
 	t := theme.CurrentTheme()
 
 	return styles.BaseStyle().
 		Foreground(t.TextMuted()).
-		Width(width).
 		Render(cwd)
 }
+
+func initialScreen() string {
+	baseStyle := styles.BaseStyle()
+
+	return baseStyle.Render(
+		lipgloss.JoinVertical(
+			lipgloss.Top,
+			header(),
+			"",
+			lspsConfigured(),
+		),
+	)
+}

internal/tui/components/chat/list.go 🔗

@@ -282,7 +282,7 @@ func (m *messagesCmp) View() string {
 			Width(m.width).
 			Height(m.height - 1).
 			Render(
-				m.initialScreen(),
+				initialScreen(),
 			)
 
 		return baseStyle.
@@ -400,19 +400,6 @@ func (m *messagesCmp) help() string {
 		Render(text)
 }
 
-func (m *messagesCmp) initialScreen() string {
-	baseStyle := styles.BaseStyle()
-
-	return baseStyle.Width(m.width).Render(
-		lipgloss.JoinVertical(
-			lipgloss.Top,
-			header(m.width),
-			"",
-			lspsConfigured(m.width),
-		),
-	)
-}
-
 func (m *messagesCmp) rerender() {
 	for _, msg := range m.messages {
 		delete(m.cachedContent, msg.ID)

internal/tui/components/chat/list_v2.go 🔗

@@ -8,6 +8,7 @@ import (
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/opencode-ai/opencode/internal/app"
 	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/pubsub"
 	"github.com/opencode-ai/opencode/internal/session"
 	"github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
 	"github.com/opencode-ai/opencode/internal/tui/components/core/list"
@@ -25,8 +26,9 @@ type messageListCmp struct {
 	app           *app.App
 	width, height int
 	session       session.Session
-	messages      []util.Model
 	listCmp       list.ListModel
+
+	lastUserMessageTime int64
 }
 
 func NewMessagesListCmp(app *app.App) MessageListCmp {
@@ -54,6 +56,12 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			return m, cmd
 		}
 		return m, nil
+	case SessionClearedMsg:
+		m.session = session.Session{}
+		return m, m.listCmp.SetItems([]util.Model{})
+
+	case pubsub.Event[message.Message]:
+		return m, m.handleMessageEvent(msg)
 	default:
 		var cmds []tea.Cmd
 		u, cmd := m.listCmp.Update(msg)
@@ -64,19 +72,91 @@ func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 }
 
 func (m *messageListCmp) View() string {
+	if len(m.listCmp.Items()) == 0 {
+		return initialScreen()
+	}
 	return lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View())
 }
 
-// GetSize implements MessageListCmp.
-func (m *messageListCmp) GetSize() (int, int) {
-	return m.width, m.height
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) {
+	// TODO: update the agent tool message with the changes
 }
 
-// SetSize implements MessageListCmp.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
-	m.width = width
-	m.height = height - 1
-	return m.listCmp.SetSize(width, height-1)
+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+	switch event.Type {
+	case pubsub.CreatedEvent:
+		if event.Payload.SessionID != m.session.ID {
+			m.handleChildSession(event)
+		}
+		messageExists := false
+		// more likely to be at the end of the list
+		items := m.listCmp.Items()
+		for i := len(items) - 1; i >= 0; i-- {
+			msg := items[i].(messages.MessageCmp)
+			if msg.GetMessage().ID == event.Payload.ID {
+				messageExists = true
+				break
+			}
+		}
+		if messageExists {
+			return nil
+		}
+		switch event.Payload.Role {
+		case message.User:
+			return m.handleNewUserMessage(event.Payload)
+		case message.Assistant:
+			return m.handleNewAssistantMessage(event.Payload)
+		}
+		// TODO: handle tools
+	case pubsub.UpdatedEvent:
+		return m.handleUpdateAssistantMessage(event.Payload)
+	}
+	return nil
+}
+
+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
+	m.lastUserMessageTime = msg.CreatedAt
+	return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
+}
+
+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+	// Simple update the content
+	items := m.listCmp.Items()
+	lastItem := items[len(items)-1].(messages.MessageCmp)
+	// TODO:handle tool calls
+	if lastItem.GetMessage().ID != msg.ID {
+		return nil
+	}
+	// for now just updet the last message
+	if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+		m.listCmp.UpdateItem(
+			len(items)-1,
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
+		)
+	}
+	return nil
+}
+
+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+	var cmds []tea.Cmd
+	// Only add assistant messages if they don't have tool calls or there is some content
+	if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+		cmd := m.listCmp.AppendItem(
+			messages.NewMessageCmp(
+				msg,
+				messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+			),
+		)
+		cmds = append(cmds, cmd)
+	}
+	for _, tc := range msg.ToolCalls() {
+		cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(tc))
+		cmds = append(cmds, cmd)
+	}
+	return tea.Batch(cmds...)
 }
 
 func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
@@ -88,8 +168,8 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	if err != nil {
 		return util.ReportError(err)
 	}
-	m.messages = make([]util.Model, 0)
-	lastUserMessageTime := sessionMessages[0].CreatedAt
+	uiMessages := make([]util.Model, 0)
+	m.lastUserMessageTime = sessionMessages[0].CreatedAt
 	toolResultMap := make(map[string]message.ToolResult)
 	// first pass to get all tool results
 	for _, msg := range sessionMessages {
@@ -100,12 +180,18 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 	for _, msg := range sessionMessages {
 		switch msg.Role {
 		case message.User:
-			lastUserMessageTime = msg.CreatedAt
-			m.messages = append(m.messages, messages.NewMessageCmp(msg))
+			m.lastUserMessageTime = msg.CreatedAt
+			uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
 		case message.Assistant:
 			// Only add assistant messages if they don't have tool calls or there is some content
 			if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
-				m.messages = append(m.messages, messages.NewMessageCmp(msg, messages.WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+				uiMessages = append(
+					uiMessages,
+					messages.NewMessageCmp(
+						msg,
+						messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+					),
+				)
 			}
 			for _, tc := range msg.ToolCalls() {
 				options := []messages.ToolCallOption{}
@@ -115,10 +201,21 @@ func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
 				if msg.FinishPart().Reason == message.FinishReasonCanceled {
 					options = append(options, messages.WithToolCallCancelled())
 				}
-				m.messages = append(m.messages, messages.NewToolCallCmp(tc, options...))
+				uiMessages = append(uiMessages, messages.NewToolCallCmp(tc, options...))
 			}
 		}
 	}
-	m.listCmp.SetItems(m.messages)
-	return nil
+	return m.listCmp.SetItems(uiMessages)
+}
+
+// GetSize implements MessageListCmp.
+func (m *messageListCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+// SetSize implements MessageListCmp.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	m.height = height - 1
+	return m.listCmp.SetSize(width, height-1)
 }

internal/tui/components/chat/messages/messages.go 🔗

@@ -12,6 +12,7 @@ import (
 	"github.com/opencode-ai/opencode/internal/llm/models"
 
 	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/tui/components/anim"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/styles"
 	"github.com/opencode-ai/opencode/internal/tui/theme"
@@ -22,6 +23,8 @@ type MessageCmp interface {
 	util.Model
 	layout.Sizeable
 	layout.Focusable
+	GetMessage() message.Message
+	Spinning() bool
 }
 
 type messageCmp struct {
@@ -30,9 +33,10 @@ type messageCmp struct {
 
 	// Used for agent and user messages
 	message             message.Message
+	spinning            bool
+	anim                util.Model
 	lastUserMessageTime time.Time
 }
-
 type MessageOption func(*messageCmp)
 
 func WithLastUserMessageTime(t time.Time) MessageOption {
@@ -44,6 +48,7 @@ func WithLastUserMessageTime(t time.Time) MessageOption {
 func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 	m := &messageCmp{
 		message: msg,
+		anim:    anim.New(15, ""),
 	}
 	for _, opt := range opts {
 		opt(m)
@@ -52,14 +57,23 @@ func NewMessageCmp(msg message.Message, opts ...MessageOption) MessageCmp {
 }
 
 func (m *messageCmp) Init() tea.Cmd {
+	m.spinning = m.shouldSpin()
+	if m.spinning {
+		return m.anim.Init()
+	}
 	return nil
 }
 
 func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
-	return m, nil
+	u, cmd := m.anim.Update(msg)
+	m.anim = u.(util.Model)
+	return m, cmd
 }
 
 func (m *messageCmp) View() string {
+	if m.spinning {
+		return m.style().PaddingLeft(1).Render(m.anim.View())
+	}
 	if m.message.ID != "" {
 		// this is a user or assistant message
 		switch m.message.Role {
@@ -72,6 +86,11 @@ func (m *messageCmp) View() string {
 	return "Unknown Message"
 }
 
+// GetMessage implements MessageCmp.
+func (m *messageCmp) GetMessage() message.Message {
+	return m.message
+}
+
 func (m *messageCmp) textWidth() int {
 	return m.width - 1 // take into account the border
 }
@@ -184,6 +203,21 @@ func (m *messageCmp) markdownContent() string {
 	return m.toMarkdown(content)
 }
 
+func (m *messageCmp) shouldSpin() bool {
+	if m.message.Role != message.Assistant {
+		return false
+	}
+
+	if m.message.IsFinished() {
+		return false
+	}
+
+	if m.message.Content().Text != "" {
+		return false
+	}
+	return true
+}
+
 // Blur implements MessageModel.
 func (m *messageCmp) Blur() tea.Cmd {
 	m.focused = false
@@ -209,3 +243,8 @@ func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
 	m.width = width
 	return nil
 }
+
+// Spinning implements MessageCmp.
+func (m *messageCmp) Spinning() bool {
+	return m.spinning
+}

internal/tui/components/chat/messages/tool.go 🔗

@@ -19,6 +19,8 @@ type ToolCallCmp interface {
 	util.Model
 	layout.Sizeable
 	layout.Focusable
+	GetToolCall() message.ToolCall
+	GetToolResult() message.ToolResult
 }
 
 type toolCallCmp struct {
@@ -73,14 +75,24 @@ func (m *toolCallCmp) View() string {
 	return box.PaddingLeft(1).Render(r.Render(m))
 }
 
-func (v *toolCallCmp) renderPending() string {
-	return fmt.Sprintf("%s: %s", prettifyToolName(v.call.Name), toolAction(v.call.Name))
+// GetToolCall implements ToolCallCmp.
+func (m *toolCallCmp) GetToolCall() message.ToolCall {
+	return m.call
 }
 
-func (msg *toolCallCmp) style() lipgloss.Style {
+// GetToolResult implements ToolCallCmp.
+func (m *toolCallCmp) GetToolResult() message.ToolResult {
+	return m.result
+}
+
+func (m *toolCallCmp) renderPending() string {
+	return fmt.Sprintf("%s: %s", prettifyToolName(m.call.Name), toolAction(m.call.Name))
+}
+
+func (m *toolCallCmp) style() lipgloss.Style {
 	t := theme.CurrentTheme()
 	borderStyle := lipgloss.NormalBorder()
-	if msg.focused {
+	if m.focused {
 		borderStyle = lipgloss.DoubleBorder()
 	}
 	return styles.BaseStyle().

internal/tui/components/chat/sidebar.go 🔗

@@ -93,11 +93,11 @@ func (m *sidebarCmp) View() string {
 		Render(
 			lipgloss.JoinVertical(
 				lipgloss.Top,
-				header(m.width),
+				header(),
 				" ",
 				m.sessionSection(),
 				" ",
-				lspsConfigured(m.width),
+				lspsConfigured(),
 				" ",
 				m.modifiedFiles(),
 			),

internal/tui/components/core/list/list.go 🔗

@@ -9,6 +9,8 @@ import (
 	"github.com/charmbracelet/bubbles/v2/key"
 	tea "github.com/charmbracelet/bubbletea/v2"
 	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/logging"
+	"github.com/opencode-ai/opencode/internal/tui/components/anim"
 	"github.com/opencode-ai/opencode/internal/tui/layout"
 	"github.com/opencode-ai/opencode/internal/tui/util"
 )
@@ -17,11 +19,17 @@ type ListModel interface {
 	util.Model
 	layout.Sizeable
 	SetItems([]util.Model) tea.Cmd
-	AppendItem(util.Model)
-	PrependItem(util.Model)
+	AppendItem(util.Model) tea.Cmd
+	PrependItem(util.Model) tea.Cmd
 	DeleteItem(int)
 	UpdateItem(int, util.Model)
 	ResetView()
+	Items() []util.Model
+}
+
+type HasAnim interface {
+	util.Model
+	Spinning() bool
 }
 
 type renderedItem struct {
@@ -107,6 +115,7 @@ func (m *model) Init() tea.Cmd {
 
 // Update implements List.
 func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	var cmds []tea.Cmd
 	switch msg := msg.(type) {
 	case tea.KeyMsg:
 		switch {
@@ -151,11 +160,37 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			m.goToBottom()
 			return m, nil
 		}
+	case anim.ColorCycleMsg:
+		logging.Info("ColorCycleMsg", "msg", msg)
+		for inx, item := range m.items {
+			if i, ok := item.(HasAnim); ok {
+				if i.Spinning() {
+					updated, cmd := i.Update(msg)
+					cmds = append(cmds, cmd)
+					m.UpdateItem(inx, updated.(util.Model))
+				}
+			}
+		}
+		return m, tea.Batch(cmds...)
+	case anim.StepCharsMsg:
+		logging.Info("ColorCycleMsg", "msg", msg)
+		for inx, item := range m.items {
+			if i, ok := item.(HasAnim); ok {
+				if i.Spinning() {
+					updated, cmd := i.Update(msg)
+					cmds = append(cmds, cmd)
+					m.UpdateItem(inx, updated.(util.Model))
+				}
+			}
+		}
+		return m, tea.Batch(cmds...)
 	}
 	if m.selectedItemInx > -1 {
 		u, cmd := m.items[m.selectedItemInx].Update(msg)
+		cmds = append(cmds, cmd)
 		m.UpdateItem(m.selectedItemInx, u.(util.Model))
-		return m, cmd
+		cmds = append(cmds, cmd)
+		return m, tea.Batch(cmds...)
 	}
 
 	return m, nil
@@ -172,6 +207,11 @@ func (m *model) View() string {
 	return lipgloss.NewStyle().Padding(m.padding...).Height(m.height).Render(m.content)
 }
 
+// Items implements ListModel.
+func (m *model) Items() []util.Model {
+	return m.items
+}
+
 func (m *model) renderVisibleReverse() {
 	start := 0
 	cutoff := m.offset + m.listHeight()
@@ -464,7 +504,6 @@ func (m *model) rerenderItem(inx int) {
 	}
 	// TODO: if hight changed do something
 	if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
-		panic("not handled")
 	}
 	m.renderedItems.Store(inx, renderedItem{
 		lines:  rerenderedLines,
@@ -563,10 +602,12 @@ func (m *model) listHeight() int {
 }
 
 // AppendItem implements List.
-func (m *model) AppendItem(item util.Model) {
+func (m *model) AppendItem(item util.Model) tea.Cmd {
+	cmd := item.Init()
 	m.items = append(m.items, item)
 	m.goToBottom()
 	m.needsRerender = true
+	return cmd
 }
 
 // DeleteItem implements List.
@@ -577,7 +618,8 @@ func (m *model) DeleteItem(i int) {
 }
 
 // PrependItem implements List.
-func (m *model) PrependItem(item util.Model) {
+func (m *model) PrependItem(item util.Model) tea.Cmd {
+	cmd := item.Init()
 	m.items = append([]util.Model{item}, m.items...)
 	// update the indices of the rendered items
 	newRenderedItems := make(map[int]renderedItem)
@@ -594,6 +636,7 @@ func (m *model) PrependItem(item util.Model) {
 	}
 	m.goToTop()
 	m.needsRerender = true
+	return cmd
 }
 
 func (m *model) setReverse(reverse bool) {
@@ -610,6 +653,9 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
 	var cmds []tea.Cmd
 	cmd := m.setItemsSize()
 	cmds = append(cmds, cmd)
+	for _, item := range m.items {
+		cmds = append(cmds, item.Init())
+	}
 	if m.reverse {
 		m.selectedItemInx = len(m.items) - 1
 		cmd := m.focusSelected()