Detailed changes
@@ -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 {
@@ -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(),
+ ),
+ )
+}
@@ -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)
@@ -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)
}
@@ -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
+}
@@ -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().
@@ -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(),
),
@@ -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()