From 25e9651655f1e2e1226297a28ad47948b722e7ab Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Thu, 22 May 2025 14:23:23 +0200 Subject: [PATCH] add anim to the messages --- 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 +++++++++++++++--- .../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(-) diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index 23eaf21c714c7370d97cf5d7ecd8a2ddc50a9aeb..aed03d946d97a7e59a8fb4d08ba8a0c2bd30ffad 100644 --- a/internal/tui/components/anim/anim.go +++ b/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 { diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index f7ccea001e1b03e08f16170c1d86db13729853e4..d261902102ddcf20edf9d735ea6b7808195a163d 100644 --- a/internal/tui/components/chat/chat.go +++ b/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(), + ), + ) +} diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go index 9cfb5c51cc91723fcedd3a03d1e11c173c33509d..7bc3c4e81281c7ce1e41153315db89bdd08d79d3 100644 --- a/internal/tui/components/chat/list.go +++ b/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) diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go index bec9b206e996149ac1574f99e3f71dbbfb8b280b..d2ad3e4e95d0d53899b662037b14c75c27e221f2 100644 --- a/internal/tui/components/chat/list_v2.go +++ b/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) } diff --git a/internal/tui/components/chat/messages/messages.go b/internal/tui/components/chat/messages/messages.go index 10d82961348413f08ced31e88385099bf85fa091..3ae278a496df5ee60472172bc761bd07e43d8662 100644 --- a/internal/tui/components/chat/messages/messages.go +++ b/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 +} diff --git a/internal/tui/components/chat/messages/tool.go b/internal/tui/components/chat/messages/tool.go index 5547170e630142f07152db19f0371c6b816a5819..9bdca071a999a031e20fe568efde27e92862a82c 100644 --- a/internal/tui/components/chat/messages/tool.go +++ b/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(). diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index 75e87335d27d83200e3b3ba9c39274bc658a4bc3..ce643d20076cd0f28c43dd18b10c47fea09facd9 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/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(), ), diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index f8b0b4fe6a8f56d9cad4d414a581f93cdea01cb1..8b6ab7cf8ace8743a4164c1ca8de28d6d5058ad0 100644 --- a/internal/tui/components/core/list/list.go +++ b/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()