Detailed changes
@@ -12,7 +12,7 @@ import (
"github.com/charmbracelet/x/ansi"
"github.com/opencode-ai/opencode/internal/config"
"github.com/opencode-ai/opencode/internal/highlight"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
"github.com/sergi/go-diff/diffmatchpatch"
)
@@ -329,12 +329,11 @@ func highlightLine(fileName string, line string, bg color.Color) string {
}
// createStyles generates the lipgloss styles needed for rendering diffs
-func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
- removedLineStyle = lipgloss.NewStyle().Background(t.DiffRemovedBg())
- addedLineStyle = lipgloss.NewStyle().Background(t.DiffAddedBg())
- contextLineStyle = lipgloss.NewStyle().Background(t.DiffContextBg())
- lineNumberStyle = lipgloss.NewStyle().Foreground(t.DiffLineNumber())
-
+func createStyles(t *styles.Theme) (removedLineStyle, addedLineStyle, contextLineStyle, lineNumberStyle lipgloss.Style) {
+ removedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.RemovedBg)
+ addedLineStyle = lipgloss.NewStyle().Background(t.S().Diff.AddedBg)
+ contextLineStyle = lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
+ lineNumberStyle = lipgloss.NewStyle().Foreground(t.S().Diff.LineNumber)
return
}
@@ -446,10 +445,10 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
// renderLeftColumn formats the left side of a side-by-side diff
func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
if dl == nil {
- contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
+ contextLineStyle := t.S().Base.Background(t.S().Diff.ContextBg)
return contextLineStyle.Width(colWidth).Render("")
}
@@ -460,9 +459,9 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
var bgStyle lipgloss.Style
switch dl.Kind {
case LineRemoved:
- marker = removedLineStyle.Foreground(t.DiffRemoved()).Render("-")
+ marker = removedLineStyle.Foreground(t.S().Diff.Removed).Render("-")
bgStyle = removedLineStyle
- lineNumberStyle = lineNumberStyle.Foreground(t.DiffRemoved()).Background(t.DiffRemovedLineNumberBg())
+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Removed).Background(t.S().Diff.RemovedLineNumberBg)
case LineAdded:
marker = "?"
bgStyle = contextLineStyle
@@ -485,7 +484,7 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
// Apply intra-line highlighting for removed lines
if dl.Kind == LineRemoved && len(dl.Segments) > 0 {
- content = applyHighlighting(content, dl.Segments, LineRemoved, t.DiffHighlightRemoved())
+ content = applyHighlighting(content, dl.Segments, LineRemoved, t.S().Diff.HighlightRemoved)
}
// Add a padding space for removed lines
@@ -499,17 +498,17 @@ func renderLeftColumn(fileName string, dl *DiffLine, colWidth int) string {
ansi.Truncate(
lineText,
colWidth,
- lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
),
)
}
// renderRightColumn formats the right side of a side-by-side diff
func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
if dl == nil {
- contextLineStyle := lipgloss.NewStyle().Background(t.DiffContextBg())
+ contextLineStyle := lipgloss.NewStyle().Background(t.S().Diff.ContextBg)
return contextLineStyle.Width(colWidth).Render("")
}
@@ -520,9 +519,9 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
var bgStyle lipgloss.Style
switch dl.Kind {
case LineAdded:
- marker = addedLineStyle.Foreground(t.DiffAdded()).Render("+")
+ marker = addedLineStyle.Foreground(t.S().Diff.Added).Render("+")
bgStyle = addedLineStyle
- lineNumberStyle = lineNumberStyle.Foreground(t.DiffAdded()).Background(t.DiffAddedLineNumberBg())
+ lineNumberStyle = lineNumberStyle.Foreground(t.S().Diff.Added).Background(t.S().Diff.AddedLineNumberBg)
case LineRemoved:
marker = "?"
bgStyle = contextLineStyle
@@ -545,7 +544,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
// Apply intra-line highlighting for added lines
if dl.Kind == LineAdded && len(dl.Segments) > 0 {
- content = applyHighlighting(content, dl.Segments, LineAdded, t.DiffHighlightAdded())
+ content = applyHighlighting(content, dl.Segments, LineAdded, t.S().Diff.HighlightAdded)
}
// Add a padding space for added lines
@@ -559,7 +558,7 @@ func renderRightColumn(fileName string, dl *DiffLine, colWidth int) string {
ansi.Truncate(
lineText,
colWidth,
- lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.TextMuted()).Render("..."),
+ lipgloss.NewStyle().Background(bgStyle.GetBackground()).Foreground(t.FgMuted).Render("..."),
),
)
}
@@ -4,18 +4,15 @@ import (
"bytes"
"fmt"
"image/color"
- "strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters"
"github.com/alecthomas/chroma/v2/lexers"
- "github.com/alecthomas/chroma/v2/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
+ chromaStyles "github.com/alecthomas/chroma/v2/styles"
+ "github.com/opencode-ai/opencode/internal/tui/styles"
)
func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
- t := theme.CurrentTheme()
-
// Determine the language lexer to use
l := lexers.Match(fileName)
if l == nil {
@@ -32,171 +29,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
f = formatters.Fallback
}
- // Dynamic theme based on current theme values
- syntaxThemeXml := fmt.Sprintf(`
- <style name="opencode-theme">
- <!-- Base colors -->
- <entry type="Text" style="%s"/>
- <entry type="Other" style="%s"/>
- <entry type="Error" style="%s"/>
- <!-- Keywords -->
- <entry type="Keyword" style="%s"/>
- <entry type="KeywordConstant" style="%s"/>
- <entry type="KeywordDeclaration" style="%s"/>
- <entry type="KeywordNamespace" style="%s"/>
- <entry type="KeywordPseudo" style="%s"/>
- <entry type="KeywordReserved" style="%s"/>
- <entry type="KeywordType" style="%s"/>
- <!-- Names -->
- <entry type="Name" style="%s"/>
- <entry type="NameAttribute" style="%s"/>
- <entry type="NameBuiltin" style="%s"/>
- <entry type="NameBuiltinPseudo" style="%s"/>
- <entry type="NameClass" style="%s"/>
- <entry type="NameConstant" style="%s"/>
- <entry type="NameDecorator" style="%s"/>
- <entry type="NameEntity" style="%s"/>
- <entry type="NameException" style="%s"/>
- <entry type="NameFunction" style="%s"/>
- <entry type="NameLabel" style="%s"/>
- <entry type="NameNamespace" style="%s"/>
- <entry type="NameOther" style="%s"/>
- <entry type="NameTag" style="%s"/>
- <entry type="NameVariable" style="%s"/>
- <entry type="NameVariableClass" style="%s"/>
- <entry type="NameVariableGlobal" style="%s"/>
- <entry type="NameVariableInstance" style="%s"/>
- <!-- Literals -->
- <entry type="Literal" style="%s"/>
- <entry type="LiteralDate" style="%s"/>
- <entry type="LiteralString" style="%s"/>
- <entry type="LiteralStringBacktick" style="%s"/>
- <entry type="LiteralStringChar" style="%s"/>
- <entry type="LiteralStringDoc" style="%s"/>
- <entry type="LiteralStringDouble" style="%s"/>
- <entry type="LiteralStringEscape" style="%s"/>
- <entry type="LiteralStringHeredoc" style="%s"/>
- <entry type="LiteralStringInterpol" style="%s"/>
- <entry type="LiteralStringOther" style="%s"/>
- <entry type="LiteralStringRegex" style="%s"/>
- <entry type="LiteralStringSingle" style="%s"/>
- <entry type="LiteralStringSymbol" style="%s"/>
- <!-- Numbers -->
- <entry type="LiteralNumber" style="%s"/>
- <entry type="LiteralNumberBin" style="%s"/>
- <entry type="LiteralNumberFloat" style="%s"/>
- <entry type="LiteralNumberHex" style="%s"/>
- <entry type="LiteralNumberInteger" style="%s"/>
- <entry type="LiteralNumberIntegerLong" style="%s"/>
- <entry type="LiteralNumberOct" style="%s"/>
- <!-- Operators -->
- <entry type="Operator" style="%s"/>
- <entry type="OperatorWord" style="%s"/>
- <entry type="Punctuation" style="%s"/>
- <!-- Comments -->
- <entry type="Comment" style="%s"/>
- <entry type="CommentHashbang" style="%s"/>
- <entry type="CommentMultiline" style="%s"/>
- <entry type="CommentSingle" style="%s"/>
- <entry type="CommentSpecial" style="%s"/>
- <entry type="CommentPreproc" style="%s"/>
- <!-- Generic styles -->
- <entry type="Generic" style="%s"/>
- <entry type="GenericDeleted" style="%s"/>
- <entry type="GenericEmph" style="italic %s"/>
- <entry type="GenericError" style="%s"/>
- <entry type="GenericHeading" style="bold %s"/>
- <entry type="GenericInserted" style="%s"/>
- <entry type="GenericOutput" style="%s"/>
- <entry type="GenericPrompt" style="%s"/>
- <entry type="GenericStrong" style="bold %s"/>
- <entry type="GenericSubheading" style="bold %s"/>
- <entry type="GenericTraceback" style="%s"/>
- <entry type="GenericUnderline" style="underline"/>
- <entry type="TextWhitespace" style="%s"/>
-</style>
-`,
- getColor(t.Text()), // Text
- getColor(t.Text()), // Other
- getColor(t.Error()), // Error
-
- getColor(t.SyntaxKeyword()), // Keyword
- getColor(t.SyntaxKeyword()), // KeywordConstant
- getColor(t.SyntaxKeyword()), // KeywordDeclaration
- getColor(t.SyntaxKeyword()), // KeywordNamespace
- getColor(t.SyntaxKeyword()), // KeywordPseudo
- getColor(t.SyntaxKeyword()), // KeywordReserved
- getColor(t.SyntaxType()), // KeywordType
-
- getColor(t.Text()), // Name
- getColor(t.SyntaxVariable()), // NameAttribute
- getColor(t.SyntaxType()), // NameBuiltin
- getColor(t.SyntaxVariable()), // NameBuiltinPseudo
- getColor(t.SyntaxType()), // NameClass
- getColor(t.SyntaxVariable()), // NameConstant
- getColor(t.SyntaxFunction()), // NameDecorator
- getColor(t.SyntaxVariable()), // NameEntity
- getColor(t.SyntaxType()), // NameException
- getColor(t.SyntaxFunction()), // NameFunction
- getColor(t.Text()), // NameLabel
- getColor(t.SyntaxType()), // NameNamespace
- getColor(t.SyntaxVariable()), // NameOther
- getColor(t.SyntaxKeyword()), // NameTag
- getColor(t.SyntaxVariable()), // NameVariable
- getColor(t.SyntaxVariable()), // NameVariableClass
- getColor(t.SyntaxVariable()), // NameVariableGlobal
- getColor(t.SyntaxVariable()), // NameVariableInstance
-
- getColor(t.SyntaxString()), // Literal
- getColor(t.SyntaxString()), // LiteralDate
- getColor(t.SyntaxString()), // LiteralString
- getColor(t.SyntaxString()), // LiteralStringBacktick
- getColor(t.SyntaxString()), // LiteralStringChar
- getColor(t.SyntaxString()), // LiteralStringDoc
- getColor(t.SyntaxString()), // LiteralStringDouble
- getColor(t.SyntaxString()), // LiteralStringEscape
- getColor(t.SyntaxString()), // LiteralStringHeredoc
- getColor(t.SyntaxString()), // LiteralStringInterpol
- getColor(t.SyntaxString()), // LiteralStringOther
- getColor(t.SyntaxString()), // LiteralStringRegex
- getColor(t.SyntaxString()), // LiteralStringSingle
- getColor(t.SyntaxString()), // LiteralStringSymbol
-
- getColor(t.SyntaxNumber()), // LiteralNumber
- getColor(t.SyntaxNumber()), // LiteralNumberBin
- getColor(t.SyntaxNumber()), // LiteralNumberFloat
- getColor(t.SyntaxNumber()), // LiteralNumberHex
- getColor(t.SyntaxNumber()), // LiteralNumberInteger
- getColor(t.SyntaxNumber()), // LiteralNumberIntegerLong
- getColor(t.SyntaxNumber()), // LiteralNumberOct
-
- getColor(t.SyntaxOperator()), // Operator
- getColor(t.SyntaxKeyword()), // OperatorWord
- getColor(t.SyntaxPunctuation()), // Punctuation
-
- getColor(t.SyntaxComment()), // Comment
- getColor(t.SyntaxComment()), // CommentHashbang
- getColor(t.SyntaxComment()), // CommentMultiline
- getColor(t.SyntaxComment()), // CommentSingle
- getColor(t.SyntaxComment()), // CommentSpecial
- getColor(t.SyntaxKeyword()), // CommentPreproc
-
- getColor(t.Text()), // Generic
- getColor(t.Error()), // GenericDeleted
- getColor(t.Text()), // GenericEmph
- getColor(t.Error()), // GenericError
- getColor(t.Text()), // GenericHeading
- getColor(t.Success()), // GenericInserted
- getColor(t.TextMuted()), // GenericOutput
- getColor(t.Text()), // GenericPrompt
- getColor(t.Text()), // GenericStrong
- getColor(t.Text()), // GenericSubheading
- getColor(t.Error()), // GenericTraceback
- getColor(t.Text()), // TextWhitespace
- )
-
- r := strings.NewReader(syntaxThemeXml)
- style := chroma.MustNewXMLStyle(r)
+ style := chroma.MustNewStyle("crush", styles.GetChromaTheme())
// Modify the style to use the provided background
s, err := style.Builder().Transform(
@@ -207,7 +40,7 @@ func SyntaxHighlight(source, fileName string, bg color.Color) (string, error) {
},
).Build()
if err != nil {
- s = styles.Fallback
+ s = chromaStyles.Fallback
}
// Tokenize and format
@@ -1,6 +1,7 @@
package anim
import (
+ "fmt"
"image/color"
"math/rand"
"strings"
@@ -12,7 +13,6 @@ import (
"github.com/google/uuid"
"github.com/lucasb-eyer/go-colorful"
"github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
"github.com/opencode-ai/opencode/internal/tui/util"
)
@@ -240,7 +240,7 @@ func (a *anim) updateChars(chars *[]cyclingChar) {
// View renders the animation.
func (a anim) View() tea.View {
- t := theme.CurrentTheme()
+ t := styles.CurrentTheme()
var b strings.Builder
for i, c := range a.cyclingChars {
@@ -252,8 +252,7 @@ func (a anim) View() tea.View {
}
if len(a.labelChars) > 1 {
- textStyle := styles.BaseStyle().
- Foreground(t.Text())
+ textStyle := t.S().Text
for _, c := range a.labelChars {
b.WriteString(
textStyle.Render(string(c.currentValue)),
@@ -265,10 +264,15 @@ func (a anim) View() tea.View {
return tea.NewView(b.String())
}
+func GetColor(c color.Color) string {
+ rgba := color.RGBAModel.Convert(c).(color.RGBA)
+ return fmt.Sprintf("#%02x%02x%02x", rgba.R, rgba.G, rgba.B)
+}
+
func makeGradientRamp(length int) []color.Color {
- t := theme.CurrentTheme()
- startColor := theme.GetColor(t.Primary())
- endColor := theme.GetColor(t.Secondary())
+ t := styles.CurrentTheme()
+ startColor := GetColor(t.Primary)
+ endColor := GetColor(t.Secondary)
var (
c = make([]color.Color, length)
start, _ = colorful.Hex(startColor)
@@ -1,17 +1,22 @@
package chat
import (
- "fmt"
- "sort"
+ "context"
+ "time"
+ "github.com/charmbracelet/bubbles/v2/key"
+ tea "github.com/charmbracelet/bubbletea/v2"
"github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/config"
+ "github.com/opencode-ai/opencode/internal/app"
+ "github.com/opencode-ai/opencode/internal/llm/agent"
"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/logo"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/version"
+ "github.com/opencode-ai/opencode/internal/tui/components/chat/messages"
+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
+ "github.com/opencode-ai/opencode/internal/tui/components/dialog"
+ "github.com/opencode-ai/opencode/internal/tui/layout"
+ "github.com/opencode-ai/opencode/internal/tui/util"
)
type SendMsg struct {
@@ -23,113 +28,470 @@ type SessionSelectedMsg = session.Session
type SessionClearedMsg struct{}
-type EditorFocusMsg bool
+const (
+ NotFound = -1
+)
+
+// MessageListCmp represents a component that displays a list of chat messages
+// with support for real-time updates and session management.
+type MessageListCmp interface {
+ util.Model
+ layout.Sizeable
+}
+
+// messageListCmp implements MessageListCmp, providing a virtualized list
+// of chat messages with support for tool calls, real-time updates, and
+// session switching.
+type messageListCmp struct {
+ app *app.App
+ width, height int
+ session session.Session
+ listCmp list.ListModel
+
+ lastUserMessageTime int64
+}
+
+// NewMessagesListCmp creates a new message list component with custom keybindings
+// and reverse ordering (newest messages at bottom).
+func NewMessagesListCmp(app *app.App) MessageListCmp {
+ defaultKeymaps := list.DefaultKeyMap()
+ defaultKeymaps.Up.SetEnabled(false)
+ defaultKeymaps.Down.SetEnabled(false)
+ defaultKeymaps.NDown = key.NewBinding(
+ key.WithKeys("ctrl+j"),
+ )
+ defaultKeymaps.NUp = key.NewBinding(
+ key.WithKeys("ctrl+k"),
+ )
+ defaultKeymaps.Home = key.NewBinding(
+ key.WithKeys("ctrl+shift+up"),
+ )
+ defaultKeymaps.End = key.NewBinding(
+ key.WithKeys("ctrl+shift+down"),
+ )
+ return &messageListCmp{
+ app: app,
+ listCmp: list.New(
+ list.WithGapSize(1),
+ list.WithReverse(true),
+ list.WithKeyMap(defaultKeymaps),
+ ),
+ }
+}
+
+// Init initializes the component (no initialization needed).
+func (m *messageListCmp) Init() tea.Cmd {
+ return nil
+}
+
+// Update handles incoming messages and updates the component state.
+func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case dialog.ThemeChangedMsg:
+ m.listCmp.ResetView()
+ return m, nil
+ case SessionSelectedMsg:
+ if msg.ID != m.session.ID {
+ cmd := m.SetSession(msg)
+ return m, cmd
+ }
+ return m, nil
+ case SessionClearedMsg:
+ m.session = session.Session{}
+ return m, m.listCmp.SetItems([]util.Model{})
+
+ case pubsub.Event[message.Message]:
+ cmd := m.handleMessageEvent(msg)
+ return m, cmd
+ default:
+ var cmds []tea.Cmd
+ u, cmd := m.listCmp.Update(msg)
+ m.listCmp = u.(list.ListModel)
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+ }
+}
-func header() string {
- return lipgloss.JoinVertical(
- lipgloss.Top,
- logoBlock(),
- repo(),
- "",
- cwd(),
+// View renders the message list or an initial screen if empty.
+func (m *messageListCmp) View() tea.View {
+ return tea.NewView(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ m.listCmp.View().String(),
+ ),
+ )
+}
+
+// handleChildSession handles messages from child sessions (agent tools).
+func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
+ var cmds []tea.Cmd
+ if len(event.Payload.ToolCalls()) == 0 {
+ return nil
+ }
+ items := m.listCmp.Items()
+ toolCallInx := NotFound
+ var toolCall messages.ToolCallCmp
+ for i := len(items) - 1; i >= 0; i-- {
+ if msg, ok := items[i].(messages.ToolCallCmp); ok {
+ if msg.GetToolCall().ID == event.Payload.SessionID {
+ toolCallInx = i
+ toolCall = msg
+ }
+ }
+ }
+ if toolCallInx == NotFound {
+ return nil
+ }
+ nestedToolCalls := toolCall.GetNestedToolCalls()
+ for _, tc := range event.Payload.ToolCalls() {
+ found := false
+ for existingInx, existingTC := range nestedToolCalls {
+ if existingTC.GetToolCall().ID == tc.ID {
+ nestedToolCalls[existingInx].SetToolCall(tc)
+ found = true
+ break
+ }
+ }
+ if !found {
+ nestedCall := messages.NewToolCallCmp(
+ event.Payload.ID,
+ tc,
+ messages.WithToolCallNested(true),
+ )
+ cmds = append(cmds, nestedCall.Init())
+ nestedToolCalls = append(
+ nestedToolCalls,
+ nestedCall,
+ )
+ }
+ }
+ toolCall.SetNestedToolCalls(nestedToolCalls)
+ m.listCmp.UpdateItem(
+ toolCallInx,
+ toolCall,
)
+ return tea.Batch(cmds...)
+}
+
+// handleMessageEvent processes different types of message events (created/updated).
+func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
+ switch event.Type {
+ case pubsub.CreatedEvent:
+ if event.Payload.SessionID != m.session.ID {
+ return m.handleChildSession(event)
+ }
+ if m.messageExists(event.Payload.ID) {
+ return nil
+ }
+ return m.handleNewMessage(event.Payload)
+ case pubsub.UpdatedEvent:
+ if event.Payload.SessionID != m.session.ID {
+ return m.handleChildSession(event)
+ }
+ return m.handleUpdateAssistantMessage(event.Payload)
+ }
+ return nil
+}
+
+// messageExists checks if a message with the given ID already exists in the list.
+func (m *messageListCmp) messageExists(messageID string) bool {
+ items := m.listCmp.Items()
+ // Search backwards as new messages are more likely to be at the end
+ for i := len(items) - 1; i >= 0; i-- {
+ if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
+ return true
+ }
+ }
+ return false
+}
+
+// handleNewMessage routes new messages to appropriate handlers based on role.
+func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
+ switch msg.Role {
+ case message.User:
+ return m.handleNewUserMessage(msg)
+ case message.Assistant:
+ return m.handleNewAssistantMessage(msg)
+ case message.Tool:
+ return m.handleToolMessage(msg)
+ }
+ return nil
+}
+
+// handleNewUserMessage adds a new user message to the list and updates the timestamp.
+func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
+ m.lastUserMessageTime = msg.CreatedAt
+ return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
+}
+
+// handleToolMessage updates existing tool calls with their results.
+func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
+ items := m.listCmp.Items()
+ for _, tr := range msg.ToolResults() {
+ if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
+ toolCall := items[toolCallIndex].(messages.ToolCallCmp)
+ toolCall.SetToolResult(tr)
+ m.listCmp.UpdateItem(toolCallIndex, toolCall)
+ }
+ }
+ return nil
+}
+
+// findToolCallByID searches for a tool call with the specified ID.
+// Returns the index if found, NotFound otherwise.
+func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
+ // Search backwards as tool calls are more likely to be recent
+ for i := len(items) - 1; i >= 0; i-- {
+ if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
+ return i
+ }
+ }
+ return NotFound
}
-func lspsConfigured() string {
- cfg := config.Get()
- title := "LSP Configuration"
+// handleUpdateAssistantMessage processes updates to assistant messages,
+// managing both message content and associated tool calls.
+func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
+ var cmds []tea.Cmd
+ items := m.listCmp.Items()
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
+ // Find existing assistant message and tool calls for this message
+ assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
+
+ // Handle assistant message content
+ if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ // Handle tool calls
+ if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+
+ return tea.Batch(cmds...)
+}
- lsps := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render(title)
+// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
+func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
+ assistantIndex := NotFound
+ toolCalls := make(map[int]messages.ToolCallCmp)
- // Get LSP names and sort them for consistent ordering
- var lspNames []string
- for name := range cfg.LSP {
- lspNames = append(lspNames, name)
+ // Search backwards as messages are more likely to be at the end
+ for i := len(items) - 1; i >= 0; i-- {
+ item := items[i]
+ if asMsg, ok := item.(messages.MessageCmp); ok {
+ if asMsg.GetMessage().ID == messageID {
+ assistantIndex = i
+ }
+ } else if tc, ok := item.(messages.ToolCallCmp); ok {
+ if tc.ParentMessageId() == messageID {
+ toolCalls[i] = tc
+ }
+ }
}
- sort.Strings(lspNames)
- var lspViews []string
- for _, name := range lspNames {
- lsp := cfg.LSP[name]
- lspName := baseStyle.
- Foreground(t.Text()).
- Render(fmt.Sprintf("• %s", name))
+ return assistantIndex, toolCalls
+}
- cmd := lsp.Command
+// updateAssistantMessageContent updates or removes the assistant message based on content.
+func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
+ if assistantIndex == NotFound {
+ return nil
+ }
- lspPath := baseStyle.
- Foreground(t.TextMuted()).
- Render(fmt.Sprintf(" (%s)", cmd))
+ shouldShowMessage := m.shouldShowAssistantMessage(msg)
+ hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
- lspViews = append(lspViews,
- baseStyle.
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- lspName,
- lspPath,
- ),
- ),
+ if shouldShowMessage {
+ m.listCmp.UpdateItem(
+ assistantIndex,
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+ ),
)
+ } else if hasToolCallsOnly {
+ m.listCmp.DeleteItem(assistantIndex)
+ }
+
+ return nil
+}
+
+// shouldShowAssistantMessage determines if an assistant message should be displayed.
+func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
+ return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
+}
+
+// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
+func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
+ var cmds []tea.Cmd
+
+ for _, tc := range msg.ToolCalls() {
+ if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
+ cmds = append(cmds, cmd)
+ }
+ }
+
+ return tea.Batch(cmds...)
+}
+
+// updateOrAddToolCall updates an existing tool call or adds a new one.
+func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
+ // Try to find existing tool call
+ for index, existingTC := range existingToolCalls {
+ if tc.ID == existingTC.GetToolCall().ID {
+ existingTC.SetToolCall(tc)
+ m.listCmp.UpdateItem(index, existingTC)
+ return nil
+ }
}
- return baseStyle.
- Render(
- lipgloss.JoinVertical(
- lipgloss.Left,
- lsps,
- lipgloss.JoinVertical(
- lipgloss.Left,
- lspViews...,
- ),
+ // Add new tool call if not found
+ return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
+}
+
+// handleNewAssistantMessage processes new assistant messages and their tool calls.
+func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
+ var cmds []tea.Cmd
+
+ // Add assistant message if it should be displayed
+ if m.shouldShowAssistantMessage(msg) {
+ cmd := m.listCmp.AppendItem(
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
),
)
+ cmds = append(cmds, cmd)
+ }
+
+ // Add tool calls
+ for _, tc := range msg.ToolCalls() {
+ cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
+ cmds = append(cmds, cmd)
+ }
+
+ return tea.Batch(cmds...)
+}
+
+// SetSession loads and displays messages for a new session.
+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
+ if m.session.ID == session.ID {
+ return nil
+ }
+
+ m.session = session
+ sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
+ if err != nil {
+ return util.ReportError(err)
+ }
+
+ if len(sessionMessages) == 0 {
+ return m.listCmp.SetItems([]util.Model{})
+ }
+
+ // Initialize with first message timestamp
+ m.lastUserMessageTime = sessionMessages[0].CreatedAt
+
+ // Build tool result map for efficient lookup
+ toolResultMap := m.buildToolResultMap(sessionMessages)
+
+ // Convert messages to UI components
+ uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
+
+ return m.listCmp.SetItems(uiMessages)
}
-func logoBlock() string {
- t := theme.CurrentTheme()
- return logo.Render(version.Version, true, logo.Opts{
- FieldColor: t.Secondary(),
- TitleColorA: t.Primary(),
- TitleColorB: t.Secondary(),
- CharmColor: t.Primary(),
- VersionColor: t.Secondary(),
- })
+// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
+func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
+ toolResultMap := make(map[string]message.ToolResult)
+ for _, msg := range messages {
+ for _, tr := range msg.ToolResults() {
+ toolResultMap[tr.ToolCallID] = tr
+ }
+ }
+ return toolResultMap
}
-func repo() string {
- repo := "https://github.com/opencode-ai/opencode"
- t := theme.CurrentTheme()
+// convertMessagesToUI converts database messages to UI components.
+func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+ uiMessages := make([]util.Model, 0)
+
+ for _, msg := range sessionMessages {
+ switch msg.Role {
+ case message.User:
+ m.lastUserMessageTime = msg.CreatedAt
+ uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
+ case message.Assistant:
+ uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
+ }
+ }
+
+ return uiMessages
+}
+
+// convertAssistantMessage converts an assistant message and its tool calls to UI components.
+func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
+ var uiMessages []util.Model
+
+ // Add assistant message if it should be displayed
+ if m.shouldShowAssistantMessage(msg) {
+ uiMessages = append(
+ uiMessages,
+ messages.NewMessageCmp(
+ msg,
+ messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
+ ),
+ )
+ }
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(repo)
+ // Add tool calls with their results and status
+ for _, tc := range msg.ToolCalls() {
+ options := m.buildToolCallOptions(tc, msg, toolResultMap)
+ uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
+ // If this tool call is the agent tool, fetch nested tool calls
+ if tc.Name == agent.AgentToolName {
+ nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
+ nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
+ nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
+ for _, nestedMsg := range nestedUIMessages {
+ if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
+ toolCall.SetIsNested(true)
+ nestedToolCalls = append(nestedToolCalls, toolCall)
+ }
+ }
+ uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
+ }
+ }
+
+ return uiMessages
}
-func cwd() string {
- cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory())
- t := theme.CurrentTheme()
+// buildToolCallOptions creates options for tool call components based on results and status.
+func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
+ var options []messages.ToolCallOption
+
+ // Add tool result if available
+ if tr, ok := toolResultMap[tc.ID]; ok {
+ options = append(options, messages.WithToolCallResult(tr))
+ }
- return styles.BaseStyle().
- Foreground(t.TextMuted()).
- Render(cwd)
+ // Add cancelled status if applicable
+ if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
+ options = append(options, messages.WithToolCallCancelled())
+ }
+
+ return options
}
-func initialScreen() string {
- baseStyle := styles.BaseStyle()
+// GetSize returns the current width and height of the component.
+func (m *messageListCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
- return baseStyle.Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(),
- "",
- lspsConfigured(),
- ),
- )
+// SetSize updates the component dimensions and propagates to the list component.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+ m.width = width
+ m.height = height - 1
+ return m.listCmp.SetSize(width, height-1)
}
@@ -1,486 +0,0 @@
-package chat
-
-import (
- "context"
- "time"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/llm/agent"
- "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"
- "github.com/opencode-ai/opencode/internal/tui/components/dialog"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-const (
- NotFound = -1
-)
-
-// MessageListCmp represents a component that displays a list of chat messages
-// with support for real-time updates and session management.
-type MessageListCmp interface {
- util.Model
- layout.Sizeable
-}
-
-// messageListCmp implements MessageListCmp, providing a virtualized list
-// of chat messages with support for tool calls, real-time updates, and
-// session switching.
-type messageListCmp struct {
- app *app.App
- width, height int
- session session.Session
- listCmp list.ListModel
-
- lastUserMessageTime int64
-}
-
-// NewMessagesListCmp creates a new message list component with custom keybindings
-// and reverse ordering (newest messages at bottom).
-func NewMessagesListCmp(app *app.App) MessageListCmp {
- defaultKeymaps := list.DefaultKeyMap()
- defaultKeymaps.Up.SetEnabled(false)
- defaultKeymaps.Down.SetEnabled(false)
- defaultKeymaps.NDown = key.NewBinding(
- key.WithKeys("ctrl+j"),
- )
- defaultKeymaps.NUp = key.NewBinding(
- key.WithKeys("ctrl+k"),
- )
- defaultKeymaps.Home = key.NewBinding(
- key.WithKeys("ctrl+shift+up"),
- )
- defaultKeymaps.End = key.NewBinding(
- key.WithKeys("ctrl+shift+down"),
- )
- return &messageListCmp{
- app: app,
- listCmp: list.New(
- list.WithGapSize(1),
- list.WithReverse(true),
- list.WithKeyMap(defaultKeymaps),
- ),
- }
-}
-
-// Init initializes the component (no initialization needed).
-func (m *messageListCmp) Init() tea.Cmd {
- return nil
-}
-
-// Update handles incoming messages and updates the component state.
-func (m *messageListCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case dialog.ThemeChangedMsg:
- m.listCmp.ResetView()
- return m, nil
- case SessionSelectedMsg:
- if msg.ID != m.session.ID {
- cmd := m.SetSession(msg)
- return m, cmd
- }
- return m, nil
- case SessionClearedMsg:
- m.session = session.Session{}
- return m, m.listCmp.SetItems([]util.Model{})
-
- case pubsub.Event[message.Message]:
- cmd := m.handleMessageEvent(msg)
- return m, cmd
- default:
- var cmds []tea.Cmd
- u, cmd := m.listCmp.Update(msg)
- m.listCmp = u.(list.ListModel)
- cmds = append(cmds, cmd)
- return m, tea.Batch(cmds...)
- }
-}
-
-// View renders the message list or an initial screen if empty.
-func (m *messageListCmp) View() tea.View {
- if len(m.listCmp.Items()) == 0 {
- return tea.NewView(initialScreen())
- }
- return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, m.listCmp.View().String()))
-}
-
-// handleChildSession handles messages from child sessions (agent tools).
-func (m *messageListCmp) handleChildSession(event pubsub.Event[message.Message]) tea.Cmd {
- var cmds []tea.Cmd
- if len(event.Payload.ToolCalls()) == 0 {
- return nil
- }
- items := m.listCmp.Items()
- toolCallInx := NotFound
- var toolCall messages.ToolCallCmp
- for i := len(items) - 1; i >= 0; i-- {
- if msg, ok := items[i].(messages.ToolCallCmp); ok {
- if msg.GetToolCall().ID == event.Payload.SessionID {
- toolCallInx = i
- toolCall = msg
- }
- }
- }
- if toolCallInx == NotFound {
- return nil
- }
- nestedToolCalls := toolCall.GetNestedToolCalls()
- for _, tc := range event.Payload.ToolCalls() {
- found := false
- for existingInx, existingTC := range nestedToolCalls {
- if existingTC.GetToolCall().ID == tc.ID {
- nestedToolCalls[existingInx].SetToolCall(tc)
- found = true
- break
- }
- }
- if !found {
- nestedCall := messages.NewToolCallCmp(
- event.Payload.ID,
- tc,
- messages.WithToolCallNested(true),
- )
- cmds = append(cmds, nestedCall.Init())
- nestedToolCalls = append(
- nestedToolCalls,
- nestedCall,
- )
- }
- }
- toolCall.SetNestedToolCalls(nestedToolCalls)
- m.listCmp.UpdateItem(
- toolCallInx,
- toolCall,
- )
- return tea.Batch(cmds...)
-}
-
-// handleMessageEvent processes different types of message events (created/updated).
-func (m *messageListCmp) handleMessageEvent(event pubsub.Event[message.Message]) tea.Cmd {
- switch event.Type {
- case pubsub.CreatedEvent:
- if event.Payload.SessionID != m.session.ID {
- return m.handleChildSession(event)
- }
- if m.messageExists(event.Payload.ID) {
- return nil
- }
- return m.handleNewMessage(event.Payload)
- case pubsub.UpdatedEvent:
- if event.Payload.SessionID != m.session.ID {
- return m.handleChildSession(event)
- }
- return m.handleUpdateAssistantMessage(event.Payload)
- }
- return nil
-}
-
-// messageExists checks if a message with the given ID already exists in the list.
-func (m *messageListCmp) messageExists(messageID string) bool {
- items := m.listCmp.Items()
- // Search backwards as new messages are more likely to be at the end
- for i := len(items) - 1; i >= 0; i-- {
- if msg, ok := items[i].(messages.MessageCmp); ok && msg.GetMessage().ID == messageID {
- return true
- }
- }
- return false
-}
-
-// handleNewMessage routes new messages to appropriate handlers based on role.
-func (m *messageListCmp) handleNewMessage(msg message.Message) tea.Cmd {
- switch msg.Role {
- case message.User:
- return m.handleNewUserMessage(msg)
- case message.Assistant:
- return m.handleNewAssistantMessage(msg)
- case message.Tool:
- return m.handleToolMessage(msg)
- }
- return nil
-}
-
-// handleNewUserMessage adds a new user message to the list and updates the timestamp.
-func (m *messageListCmp) handleNewUserMessage(msg message.Message) tea.Cmd {
- m.lastUserMessageTime = msg.CreatedAt
- return m.listCmp.AppendItem(messages.NewMessageCmp(msg))
-}
-
-// handleToolMessage updates existing tool calls with their results.
-func (m *messageListCmp) handleToolMessage(msg message.Message) tea.Cmd {
- items := m.listCmp.Items()
- for _, tr := range msg.ToolResults() {
- if toolCallIndex := m.findToolCallByID(items, tr.ToolCallID); toolCallIndex != NotFound {
- toolCall := items[toolCallIndex].(messages.ToolCallCmp)
- toolCall.SetToolResult(tr)
- m.listCmp.UpdateItem(toolCallIndex, toolCall)
- }
- }
- return nil
-}
-
-// findToolCallByID searches for a tool call with the specified ID.
-// Returns the index if found, NotFound otherwise.
-func (m *messageListCmp) findToolCallByID(items []util.Model, toolCallID string) int {
- // Search backwards as tool calls are more likely to be recent
- for i := len(items) - 1; i >= 0; i-- {
- if toolCall, ok := items[i].(messages.ToolCallCmp); ok && toolCall.GetToolCall().ID == toolCallID {
- return i
- }
- }
- return NotFound
-}
-
-// handleUpdateAssistantMessage processes updates to assistant messages,
-// managing both message content and associated tool calls.
-func (m *messageListCmp) handleUpdateAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
- items := m.listCmp.Items()
-
- // Find existing assistant message and tool calls for this message
- assistantIndex, existingToolCalls := m.findAssistantMessageAndToolCalls(items, msg.ID)
-
- // Handle assistant message content
- if cmd := m.updateAssistantMessageContent(msg, assistantIndex); cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- // Handle tool calls
- if cmd := m.updateToolCalls(msg, existingToolCalls); cmd != nil {
- cmds = append(cmds, cmd)
- }
-
- return tea.Batch(cmds...)
-}
-
-// findAssistantMessageAndToolCalls locates the assistant message and its tool calls.
-func (m *messageListCmp) findAssistantMessageAndToolCalls(items []util.Model, messageID string) (int, map[int]messages.ToolCallCmp) {
- assistantIndex := NotFound
- toolCalls := make(map[int]messages.ToolCallCmp)
-
- // Search backwards as messages are more likely to be at the end
- for i := len(items) - 1; i >= 0; i-- {
- item := items[i]
- if asMsg, ok := item.(messages.MessageCmp); ok {
- if asMsg.GetMessage().ID == messageID {
- assistantIndex = i
- }
- } else if tc, ok := item.(messages.ToolCallCmp); ok {
- if tc.ParentMessageId() == messageID {
- toolCalls[i] = tc
- }
- }
- }
-
- return assistantIndex, toolCalls
-}
-
-// updateAssistantMessageContent updates or removes the assistant message based on content.
-func (m *messageListCmp) updateAssistantMessageContent(msg message.Message, assistantIndex int) tea.Cmd {
- if assistantIndex == NotFound {
- return nil
- }
-
- shouldShowMessage := m.shouldShowAssistantMessage(msg)
- hasToolCallsOnly := len(msg.ToolCalls()) > 0 && msg.Content().Text == ""
-
- if shouldShowMessage {
- m.listCmp.UpdateItem(
- assistantIndex,
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- } else if hasToolCallsOnly {
- m.listCmp.DeleteItem(assistantIndex)
- }
-
- return nil
-}
-
-// shouldShowAssistantMessage determines if an assistant message should be displayed.
-func (m *messageListCmp) shouldShowAssistantMessage(msg message.Message) bool {
- return len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking()
-}
-
-// updateToolCalls handles updates to tool calls, updating existing ones and adding new ones.
-func (m *messageListCmp) updateToolCalls(msg message.Message, existingToolCalls map[int]messages.ToolCallCmp) tea.Cmd {
- var cmds []tea.Cmd
-
- for _, tc := range msg.ToolCalls() {
- if cmd := m.updateOrAddToolCall(tc, existingToolCalls, msg.ID); cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
-
- return tea.Batch(cmds...)
-}
-
-// updateOrAddToolCall updates an existing tool call or adds a new one.
-func (m *messageListCmp) updateOrAddToolCall(tc message.ToolCall, existingToolCalls map[int]messages.ToolCallCmp, messageID string) tea.Cmd {
- // Try to find existing tool call
- for index, existingTC := range existingToolCalls {
- if tc.ID == existingTC.GetToolCall().ID {
- existingTC.SetToolCall(tc)
- m.listCmp.UpdateItem(index, existingTC)
- return nil
- }
- }
-
- // Add new tool call if not found
- return m.listCmp.AppendItem(messages.NewToolCallCmp(messageID, tc))
-}
-
-// handleNewAssistantMessage processes new assistant messages and their tool calls.
-func (m *messageListCmp) handleNewAssistantMessage(msg message.Message) tea.Cmd {
- var cmds []tea.Cmd
-
- // Add assistant message if it should be displayed
- if m.shouldShowAssistantMessage(msg) {
- cmd := m.listCmp.AppendItem(
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- cmds = append(cmds, cmd)
- }
-
- // Add tool calls
- for _, tc := range msg.ToolCalls() {
- cmd := m.listCmp.AppendItem(messages.NewToolCallCmp(msg.ID, tc))
- cmds = append(cmds, cmd)
- }
-
- return tea.Batch(cmds...)
-}
-
-// SetSession loads and displays messages for a new session.
-func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
- if m.session.ID == session.ID {
- return nil
- }
-
- m.session = session
- sessionMessages, err := m.app.Messages.List(context.Background(), session.ID)
- if err != nil {
- return util.ReportError(err)
- }
-
- if len(sessionMessages) == 0 {
- return m.listCmp.SetItems([]util.Model{})
- }
-
- // Initialize with first message timestamp
- m.lastUserMessageTime = sessionMessages[0].CreatedAt
-
- // Build tool result map for efficient lookup
- toolResultMap := m.buildToolResultMap(sessionMessages)
-
- // Convert messages to UI components
- uiMessages := m.convertMessagesToUI(sessionMessages, toolResultMap)
-
- return m.listCmp.SetItems(uiMessages)
-}
-
-// buildToolResultMap creates a map of tool call ID to tool result for efficient lookup.
-func (m *messageListCmp) buildToolResultMap(messages []message.Message) map[string]message.ToolResult {
- toolResultMap := make(map[string]message.ToolResult)
- for _, msg := range messages {
- for _, tr := range msg.ToolResults() {
- toolResultMap[tr.ToolCallID] = tr
- }
- }
- return toolResultMap
-}
-
-// convertMessagesToUI converts database messages to UI components.
-func (m *messageListCmp) convertMessagesToUI(sessionMessages []message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
- uiMessages := make([]util.Model, 0)
-
- for _, msg := range sessionMessages {
- switch msg.Role {
- case message.User:
- m.lastUserMessageTime = msg.CreatedAt
- uiMessages = append(uiMessages, messages.NewMessageCmp(msg))
- case message.Assistant:
- uiMessages = append(uiMessages, m.convertAssistantMessage(msg, toolResultMap)...)
- }
- }
-
- return uiMessages
-}
-
-// convertAssistantMessage converts an assistant message and its tool calls to UI components.
-func (m *messageListCmp) convertAssistantMessage(msg message.Message, toolResultMap map[string]message.ToolResult) []util.Model {
- var uiMessages []util.Model
-
- // Add assistant message if it should be displayed
- if m.shouldShowAssistantMessage(msg) {
- uiMessages = append(
- uiMessages,
- messages.NewMessageCmp(
- msg,
- messages.WithLastUserMessageTime(time.Unix(m.lastUserMessageTime, 0)),
- ),
- )
- }
-
- // Add tool calls with their results and status
- for _, tc := range msg.ToolCalls() {
- options := m.buildToolCallOptions(tc, msg, toolResultMap)
- uiMessages = append(uiMessages, messages.NewToolCallCmp(msg.ID, tc, options...))
- // If this tool call is the agent tool, fetch nested tool calls
- if tc.Name == agent.AgentToolName {
- nestedMessages, _ := m.app.Messages.List(context.Background(), tc.ID)
- nestedUIMessages := m.convertMessagesToUI(nestedMessages, make(map[string]message.ToolResult))
- nestedToolCalls := make([]messages.ToolCallCmp, 0, len(nestedUIMessages))
- for _, nestedMsg := range nestedUIMessages {
- if toolCall, ok := nestedMsg.(messages.ToolCallCmp); ok {
- toolCall.SetIsNested(true)
- nestedToolCalls = append(nestedToolCalls, toolCall)
- }
- }
- uiMessages[len(uiMessages)-1].(messages.ToolCallCmp).SetNestedToolCalls(nestedToolCalls)
- }
- }
-
- return uiMessages
-}
-
-// buildToolCallOptions creates options for tool call components based on results and status.
-func (m *messageListCmp) buildToolCallOptions(tc message.ToolCall, msg message.Message, toolResultMap map[string]message.ToolResult) []messages.ToolCallOption {
- var options []messages.ToolCallOption
-
- // Add tool result if available
- if tr, ok := toolResultMap[tc.ID]; ok {
- options = append(options, messages.WithToolCallResult(tr))
- }
-
- // Add cancelled status if applicable
- if msg.FinishPart() != nil && msg.FinishPart().Reason == message.FinishReasonCanceled {
- options = append(options, messages.WithToolCallCancelled())
- }
-
- return options
-}
-
-// GetSize returns the current width and height of the component.
-func (m *messageListCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-// SetSize updates the component dimensions and propagates to the list component.
-func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
- m.width = width
- m.height = height - 1
- return m.listCmp.SetSize(width, height-1)
-}
@@ -1,381 +0,0 @@
-package chat
-
-import (
- "context"
- "fmt"
- "sort"
- "strings"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/diff"
- "github.com/opencode-ai/opencode/internal/history"
- "github.com/opencode-ai/opencode/internal/pubsub"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type sidebarCmp struct {
- width, height int
- session session.Session
- history history.Service
- modFiles map[string]struct {
- additions int
- removals int
- }
-}
-
-func (m *sidebarCmp) Init() tea.Cmd {
- if m.history != nil {
- ctx := context.Background()
- // Subscribe to file events
- filesCh := m.history.Subscribe(ctx)
-
- // Initialize the modified files map
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
-
- // Load initial files and calculate diffs
- m.loadModifiedFiles(ctx)
-
- // Return a command that will send file events to the Update method
- return func() tea.Msg {
- return <-filesCh
- }
- }
- return nil
-}
-
-func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case SessionSelectedMsg:
- if msg.ID != m.session.ID {
- m.session = msg
- ctx := context.Background()
- m.loadModifiedFiles(ctx)
- }
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent {
- if m.session.ID == msg.Payload.ID {
- m.session = msg.Payload
- }
- }
- case pubsub.Event[history.File]:
- if msg.Payload.SessionID == m.session.ID {
- // Process the individual file change instead of reloading all files
- ctx := context.Background()
- m.processFileChanges(ctx, msg.Payload)
-
- // Return a command to continue receiving events
- return m, func() tea.Msg {
- ctx := context.Background()
- filesCh := m.history.Subscribe(ctx)
- return <-filesCh
- }
- }
- }
- return m, nil
-}
-
-func (m *sidebarCmp) View() tea.View {
- baseStyle := styles.BaseStyle()
-
- return tea.NewView(
- baseStyle.
- Width(m.width).
- PaddingLeft(4).
- PaddingRight(2).
- Height(m.height - 1).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- header(),
- " ",
- m.sessionSection(),
- " ",
- lspsConfigured(),
- " ",
- m.modifiedFiles(),
- ),
- ),
- )
-}
-
-func (m *sidebarCmp) sessionSection() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- sessionKey := baseStyle.
- Foreground(t.Primary()).
- Bold(true).
- Render("Session")
-
- sessionValue := baseStyle.
- Foreground(t.Text()).
- Width(m.width - lipgloss.Width(sessionKey)).
- Render(fmt.Sprintf(": %s", m.session.Title))
-
- return lipgloss.JoinHorizontal(
- lipgloss.Left,
- sessionKey,
- sessionValue,
- )
-}
-
-func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- stats := ""
- if additions > 0 && removals > 0 {
- additionsStr := baseStyle.
- Foreground(t.Success()).
- PaddingLeft(1).
- Render(fmt.Sprintf("+%d", additions))
-
- removalsStr := baseStyle.
- Foreground(t.Error()).
- PaddingLeft(1).
- Render(fmt.Sprintf("-%d", removals))
-
- content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
- stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
- } else if additions > 0 {
- additionsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Success()).
- Render(fmt.Sprintf("+%d", additions)))
- stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
- } else if removals > 0 {
- removalsStr := fmt.Sprintf(" %s", baseStyle.
- PaddingLeft(1).
- Foreground(t.Error()).
- Render(fmt.Sprintf("-%d", removals)))
- stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
- }
-
- filePathStr := baseStyle.Render(filePath)
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinHorizontal(
- lipgloss.Left,
- filePathStr,
- stats,
- ),
- )
-}
-
-func (m *sidebarCmp) modifiedFiles() string {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- modifiedFiles := baseStyle.
- Width(m.width).
- Foreground(t.Primary()).
- Bold(true).
- Render("Modified Files:")
-
- // If no modified files, show a placeholder message
- if len(m.modFiles) == 0 {
- message := "No modified files"
- remainingWidth := m.width - lipgloss.Width(message)
- if remainingWidth > 0 {
- message += strings.Repeat(" ", remainingWidth)
- }
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- baseStyle.Foreground(t.TextMuted()).Render(message),
- ),
- )
- }
-
- // Sort file paths alphabetically for consistent ordering
- var paths []string
- for path := range m.modFiles {
- paths = append(paths, path)
- }
- sort.Strings(paths)
-
- // Create views for each file in sorted order
- var fileViews []string
- for _, path := range paths {
- stats := m.modFiles[path]
- fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
- }
-
- return baseStyle.
- Width(m.width).
- Render(
- lipgloss.JoinVertical(
- lipgloss.Top,
- modifiedFiles,
- lipgloss.JoinVertical(
- lipgloss.Left,
- fileViews...,
- ),
- ),
- )
-}
-
-func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
- m.width = width
- m.height = height
- return nil
-}
-
-func (m *sidebarCmp) GetSize() (int, int) {
- return m.width, m.height
-}
-
-func NewSidebarCmp(session session.Session, history history.Service) util.Model {
- return &sidebarCmp{
- session: session,
- history: history,
- }
-}
-
-func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) {
- if m.history == nil || m.session.ID == "" {
- return
- }
-
- // Get all latest files for this session
- latestFiles, err := m.history.ListLatestSessionFiles(ctx, m.session.ID)
- if err != nil {
- return
- }
-
- // Get all files for this session (to find initial versions)
- allFiles, err := m.history.ListBySession(ctx, m.session.ID)
- if err != nil {
- return
- }
-
- // Clear the existing map to rebuild it
- m.modFiles = make(map[string]struct {
- additions int
- removals int
- })
-
- // Process each latest file
- for _, file := range latestFiles {
- // Skip if this is the initial version (no changes to show)
- if file.Version == history.InitialVersion {
- continue
- }
-
- // Find the initial version for this specific file
- var initialVersion history.File
- for _, v := range allFiles {
- if v.Path == file.Path && v.Version == history.InitialVersion {
- initialVersion = v
- break
- }
- }
-
- // Skip if we can't find the initial version
- if initialVersion.ID == "" {
- continue
- }
- if initialVersion.Content == file.Content {
- continue
- }
-
- // Calculate diff between initial and latest version
- _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
-
- // Only add to modified files if there are changes
- if additions > 0 || removals > 0 {
- // Remove working directory prefix from file path
- displayPath := file.Path
- workingDir := config.WorkingDirectory()
- displayPath = strings.TrimPrefix(displayPath, workingDir)
- displayPath = strings.TrimPrefix(displayPath, "/")
-
- m.modFiles[displayPath] = struct {
- additions int
- removals int
- }{
- additions: additions,
- removals: removals,
- }
- }
- }
-}
-
-func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) {
- // Skip if this is the initial version (no changes to show)
- if file.Version == history.InitialVersion {
- return
- }
-
- // Find the initial version for this file
- initialVersion, err := m.findInitialVersion(ctx, file.Path)
- if err != nil || initialVersion.ID == "" {
- return
- }
-
- // Skip if content hasn't changed
- if initialVersion.Content == file.Content {
- // If this file was previously modified but now matches the initial version,
- // remove it from the modified files list
- displayPath := getDisplayPath(file.Path)
- delete(m.modFiles, displayPath)
- return
- }
-
- // Calculate diff between initial and latest version
- _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path)
-
- // Only add to modified files if there are changes
- if additions > 0 || removals > 0 {
- displayPath := getDisplayPath(file.Path)
- m.modFiles[displayPath] = struct {
- additions int
- removals int
- }{
- additions: additions,
- removals: removals,
- }
- } else {
- // If no changes, remove from modified files
- displayPath := getDisplayPath(file.Path)
- delete(m.modFiles, displayPath)
- }
-}
-
-// Helper function to find the initial version of a file
-func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) {
- // Get all versions of this file for the session
- fileVersions, err := m.history.ListBySession(ctx, m.session.ID)
- if err != nil {
- return history.File{}, err
- }
-
- // Find the initial version
- for _, v := range fileVersions {
- if v.Path == path && v.Version == history.InitialVersion {
- return v, nil
- }
- }
-
- return history.File{}, fmt.Errorf("initial version not found")
-}
-
-// Helper function to get the display path for a file
-func getDisplayPath(path string) string {
- workingDir := config.WorkingDirectory()
- displayPath := strings.TrimPrefix(path, workingDir)
- return strings.TrimPrefix(displayPath, "/")
-}
@@ -1,329 +0,0 @@
-package core
-
-import (
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/config"
- "github.com/opencode-ai/opencode/internal/llm/models"
- "github.com/opencode-ai/opencode/internal/logging"
- "github.com/opencode-ai/opencode/internal/lsp"
- "github.com/opencode-ai/opencode/internal/lsp/protocol"
- "github.com/opencode-ai/opencode/internal/pubsub"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/components/chat"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type StatusCmp interface {
- util.Model
-}
-
-type statusCmp struct {
- info util.InfoMsg
- width int
- messageTTL time.Duration
- lspClients map[string]*lsp.Client
- session session.Session
-}
-
-// clearMessageCmd is a command that clears status messages after a timeout
-func (m statusCmp) clearMessageCmd(ttl time.Duration) tea.Cmd {
- return tea.Tick(ttl, func(time.Time) tea.Msg {
- return util.ClearStatusMsg{}
- })
-}
-
-func (m statusCmp) Init() tea.Cmd {
- return nil
-}
-
-func (m statusCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- m.width = msg.Width
- return m, nil
-
- // Handle sesson messages
- case chat.SessionSelectedMsg:
- m.session = msg
- case chat.SessionClearedMsg:
- m.session = session.Session{}
- case pubsub.Event[session.Session]:
- if msg.Type == pubsub.UpdatedEvent {
- if m.session.ID == msg.Payload.ID {
- m.session = msg.Payload
- }
- }
-
- // Handle status info
- case util.InfoMsg:
- m.info = msg
- ttl := msg.TTL
- if ttl == 0 {
- ttl = m.messageTTL
- }
- return m, m.clearMessageCmd(ttl)
- case util.ClearStatusMsg:
- m.info = util.InfoMsg{}
-
- // Handle persistent logs
- case pubsub.Event[logging.LogMessage]:
- if msg.Payload.Persist {
- switch msg.Payload.Level {
- case "error":
- m.info = util.InfoMsg{
- Type: util.InfoTypeError,
- Msg: msg.Payload.Message,
- TTL: msg.Payload.PersistTime,
- }
- case "info":
- m.info = util.InfoMsg{
- Type: util.InfoTypeInfo,
- Msg: msg.Payload.Message,
- TTL: msg.Payload.PersistTime,
- }
- case "warn":
- m.info = util.InfoMsg{
- Type: util.InfoTypeWarn,
- Msg: msg.Payload.Message,
- TTL: msg.Payload.PersistTime,
- }
- default:
- m.info = util.InfoMsg{
- Type: util.InfoTypeInfo,
- Msg: msg.Payload.Message,
- TTL: msg.Payload.PersistTime,
- }
- }
- }
- }
- return m, nil
-}
-
-var helpWidget = ""
-
-// getHelpWidget returns the help widget with current theme colors
-func getHelpWidget() string {
- t := theme.CurrentTheme()
- helpText := "ctrl+? help"
-
- return styles.Padded().
- Background(t.TextMuted()).
- Foreground(t.BackgroundDarker()).
- Bold(true).
- Render(helpText)
-}
-
-func formatTokensAndCost(tokens, contextWindow int64, cost float64) string {
- // Format tokens in human-readable format (e.g., 110K, 1.2M)
- var formattedTokens string
- switch {
- case tokens >= 1_000_000:
- formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
- case tokens >= 1_000:
- formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
- default:
- formattedTokens = fmt.Sprintf("%d", tokens)
- }
-
- // Remove .0 suffix if present
- if strings.HasSuffix(formattedTokens, ".0K") {
- formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
- }
- if strings.HasSuffix(formattedTokens, ".0M") {
- formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
- }
-
- // Format cost with $ symbol and 2 decimal places
- formattedCost := fmt.Sprintf("$%.2f", cost)
-
- percentage := (float64(tokens) / float64(contextWindow)) * 100
- if percentage > 80 {
- // add the warning icon and percentage
- formattedTokens = fmt.Sprintf("%s(%d%%)", styles.WarningIcon, int(percentage))
- }
-
- return fmt.Sprintf("Context: %s, Cost: %s", formattedTokens, formattedCost)
-}
-
-func (m statusCmp) View() tea.View {
- t := theme.CurrentTheme()
- modelID := config.Get().Agents[config.AgentCoder].Model
- model := models.SupportedModels[modelID]
-
- // Initialize the help widget
- status := getHelpWidget()
-
- tokenInfoWidth := 0
- if m.session.ID != "" {
- totalTokens := m.session.PromptTokens + m.session.CompletionTokens
- tokens := formatTokensAndCost(totalTokens, model.ContextWindow, m.session.Cost)
- tokensStyle := styles.Padded().
- Background(t.Text()).
- Foreground(t.BackgroundSecondary())
- percentage := (float64(totalTokens) / float64(model.ContextWindow)) * 100
- if percentage > 80 {
- tokensStyle = tokensStyle.Background(t.Warning())
- }
- tokenInfoWidth = lipgloss.Width(tokens) + 2
- status += tokensStyle.Render(tokens)
- }
-
- diagnostics := styles.Padded().
- Background(t.BackgroundDarker()).
- Render(m.projectDiagnostics())
-
- availableWidht := max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokenInfoWidth)
-
- if m.info.Msg != "" {
- infoStyle := styles.Padded().
- Foreground(t.Background()).
- Width(availableWidht)
-
- switch m.info.Type {
- case util.InfoTypeInfo:
- infoStyle = infoStyle.Background(t.Info())
- case util.InfoTypeWarn:
- infoStyle = infoStyle.Background(t.Warning())
- case util.InfoTypeError:
- infoStyle = infoStyle.Background(t.Error())
- }
-
- infoWidth := availableWidht - 10
- // Truncate message if it's longer than available width
- msg := m.info.Msg
- if len(msg) > infoWidth && infoWidth > 0 {
- msg = msg[:infoWidth] + "..."
- }
- status += infoStyle.Render(msg)
- } else {
- status += styles.Padded().
- Foreground(t.Text()).
- Background(t.BackgroundSecondary()).
- Width(availableWidht).
- Render("")
- }
-
- status += diagnostics
- status += m.model()
- return tea.NewView(status)
-}
-
-func (m *statusCmp) projectDiagnostics() string {
- t := theme.CurrentTheme()
-
- // Check if any LSP server is still initializing
- initializing := false
- for _, client := range m.lspClients {
- if client.GetServerState() == lsp.StateStarting {
- initializing = true
- break
- }
- }
-
- // If any server is initializing, show that status
- if initializing {
- return lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
- Foreground(t.Warning()).
- Render(fmt.Sprintf("%s Initializing LSP...", styles.SpinnerIcon))
- }
-
- errorDiagnostics := []protocol.Diagnostic{}
- warnDiagnostics := []protocol.Diagnostic{}
- hintDiagnostics := []protocol.Diagnostic{}
- infoDiagnostics := []protocol.Diagnostic{}
- for _, client := range m.lspClients {
- for _, d := range client.GetDiagnostics() {
- for _, diag := range d {
- switch diag.Severity {
- case protocol.SeverityError:
- errorDiagnostics = append(errorDiagnostics, diag)
- case protocol.SeverityWarning:
- warnDiagnostics = append(warnDiagnostics, diag)
- case protocol.SeverityHint:
- hintDiagnostics = append(hintDiagnostics, diag)
- case protocol.SeverityInformation:
- infoDiagnostics = append(infoDiagnostics, diag)
- }
- }
- }
- }
-
- if len(errorDiagnostics) == 0 && len(warnDiagnostics) == 0 && len(hintDiagnostics) == 0 && len(infoDiagnostics) == 0 {
- return "No diagnostics"
- }
-
- diagnostics := []string{}
-
- if len(errorDiagnostics) > 0 {
- errStr := lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
- Foreground(t.Error()).
- Render(fmt.Sprintf("%s %d", styles.ErrorIcon, len(errorDiagnostics)))
- diagnostics = append(diagnostics, errStr)
- }
- if len(warnDiagnostics) > 0 {
- warnStr := lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
- Foreground(t.Warning()).
- Render(fmt.Sprintf("%s %d", styles.WarningIcon, len(warnDiagnostics)))
- diagnostics = append(diagnostics, warnStr)
- }
- if len(hintDiagnostics) > 0 {
- hintStr := lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
- Foreground(t.Text()).
- Render(fmt.Sprintf("%s %d", styles.HintIcon, len(hintDiagnostics)))
- diagnostics = append(diagnostics, hintStr)
- }
- if len(infoDiagnostics) > 0 {
- infoStr := lipgloss.NewStyle().
- Background(t.BackgroundDarker()).
- Foreground(t.Info()).
- Render(fmt.Sprintf("%s %d", styles.InfoIcon, len(infoDiagnostics)))
- diagnostics = append(diagnostics, infoStr)
- }
-
- return strings.Join(diagnostics, " ")
-}
-
-func (m statusCmp) availableFooterMsgWidth(diagnostics, tokenInfo string) int {
- tokensWidth := 0
- if m.session.ID != "" {
- tokensWidth = lipgloss.Width(tokenInfo) + 2
- }
- return max(0, m.width-lipgloss.Width(helpWidget)-lipgloss.Width(m.model())-lipgloss.Width(diagnostics)-tokensWidth)
-}
-
-func (m statusCmp) model() string {
- t := theme.CurrentTheme()
-
- cfg := config.Get()
-
- coder, ok := cfg.Agents[config.AgentCoder]
- if !ok {
- return "Unknown"
- }
- model := models.SupportedModels[coder.Model]
-
- return styles.Padded().
- Background(t.Secondary()).
- Foreground(t.Background()).
- Render(model.Name)
-}
-
-func NewStatusCmp(lspClients map[string]*lsp.Client) StatusCmp {
- helpWidget = getHelpWidget()
-
- return &statusCmp{
- messageTTL: 10 * time.Second,
- lspClients: lspClients,
- }
-}
@@ -1,164 +0,0 @@
-package utilComponents
-
-import (
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/charmbracelet/lipgloss/v2"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/styles"
- "github.com/opencode-ai/opencode/internal/tui/theme"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-type SimpleListItem interface {
- Render(selected bool, width int) string
-}
-
-type SimpleList[T SimpleListItem] interface {
- util.Model
- layout.Bindings
- SetMaxWidth(maxWidth int)
- GetSelectedItem() (item T, idx int)
- SetItems(items []T)
- GetItems() []T
-}
-
-type simpleListCmp[T SimpleListItem] struct {
- fallbackMsg string
- items []T
- selectedIdx int
- maxWidth int
- maxVisibleItems int
- useAlphaNumericKeys bool
- width int
- height int
-}
-
-type simpleListKeyMap struct {
- Up key.Binding
- Down key.Binding
- UpAlpha key.Binding
- DownAlpha key.Binding
-}
-
-var simpleListKeys = simpleListKeyMap{
- Up: key.NewBinding(
- key.WithKeys("up"),
- key.WithHelp("↑", "previous list item"),
- ),
- Down: key.NewBinding(
- key.WithKeys("down"),
- key.WithHelp("↓", "next list item"),
- ),
- UpAlpha: key.NewBinding(
- key.WithKeys("k"),
- key.WithHelp("k", "previous list item"),
- ),
- DownAlpha: key.NewBinding(
- key.WithKeys("j"),
- key.WithHelp("j", "next list item"),
- ),
-}
-
-func (c *simpleListCmp[T]) Init() tea.Cmd {
- return nil
-}
-
-func (c *simpleListCmp[T]) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, simpleListKeys.Up) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.UpAlpha)):
- if c.selectedIdx > 0 {
- c.selectedIdx--
- }
- return c, nil
- case key.Matches(msg, simpleListKeys.Down) || (c.useAlphaNumericKeys && key.Matches(msg, simpleListKeys.DownAlpha)):
- if c.selectedIdx < len(c.items)-1 {
- c.selectedIdx++
- }
- return c, nil
- }
- }
-
- return c, nil
-}
-
-func (c *simpleListCmp[T]) BindingKeys() []key.Binding {
- return layout.KeyMapToSlice(simpleListKeys)
-}
-
-func (c *simpleListCmp[T]) GetSelectedItem() (T, int) {
- if len(c.items) > 0 {
- return c.items[c.selectedIdx], c.selectedIdx
- }
-
- var zero T
- return zero, -1
-}
-
-func (c *simpleListCmp[T]) SetItems(items []T) {
- c.selectedIdx = 0
- c.items = items
-}
-
-func (c *simpleListCmp[T]) GetItems() []T {
- return c.items
-}
-
-func (c *simpleListCmp[T]) SetMaxWidth(width int) {
- c.maxWidth = width
-}
-
-func (c *simpleListCmp[T]) View() tea.View {
- t := theme.CurrentTheme()
- baseStyle := styles.BaseStyle()
-
- items := c.items
- maxWidth := c.maxWidth
- maxVisibleItems := min(c.maxVisibleItems, len(items))
- startIdx := 0
-
- if len(items) <= 0 {
- return tea.NewView(
- baseStyle.
- Background(t.Background()).
- Padding(0, 1).
- Width(maxWidth).
- Render(c.fallbackMsg),
- )
- }
-
- if len(items) > maxVisibleItems {
- halfVisible := maxVisibleItems / 2
- if c.selectedIdx >= halfVisible && c.selectedIdx < len(items)-halfVisible {
- startIdx = c.selectedIdx - halfVisible
- } else if c.selectedIdx >= len(items)-halfVisible {
- startIdx = len(items) - maxVisibleItems
- }
- }
-
- endIdx := min(startIdx+maxVisibleItems, len(items))
-
- listItems := make([]string, 0, maxVisibleItems)
-
- for i := startIdx; i < endIdx; i++ {
- item := items[i]
- title := item.Render(i == c.selectedIdx, maxWidth)
- listItems = append(listItems, title)
- }
-
- return tea.NewView(
- lipgloss.JoinVertical(lipgloss.Left, listItems...),
- )
-}
-
-func NewSimpleList[T SimpleListItem](items []T, maxVisibleItems int, fallbackMsg string, useAlphaNumericKeys bool) SimpleList[T] {
- return &simpleListCmp[T]{
- fallbackMsg: fallbackMsg,
- items: items,
- maxVisibleItems: maxVisibleItems,
- useAlphaNumericKeys: useAlphaNumericKeys,
- selectedIdx: 0,
- }
-}
@@ -1,176 +0,0 @@
-package page
-
-import (
- "context"
-
- "github.com/charmbracelet/bubbles/v2/key"
- tea "github.com/charmbracelet/bubbletea/v2"
- "github.com/opencode-ai/opencode/internal/app"
- "github.com/opencode-ai/opencode/internal/logging"
- "github.com/opencode-ai/opencode/internal/message"
- "github.com/opencode-ai/opencode/internal/session"
- "github.com/opencode-ai/opencode/internal/tui/components/chat"
- "github.com/opencode-ai/opencode/internal/tui/components/chat/editor"
- "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
- "github.com/opencode-ai/opencode/internal/tui/layout"
- "github.com/opencode-ai/opencode/internal/tui/util"
-)
-
-var ChatPage PageID = "chat"
-
-type chatPage struct {
- app *app.App
- editor layout.Container
- messages layout.Container
- layout layout.SplitPaneLayout
- session session.Session
-}
-
-type ChatKeyMap struct {
- NewSession key.Binding
- Cancel key.Binding
-}
-
-var keyMap = ChatKeyMap{
- NewSession: key.NewBinding(
- key.WithKeys("ctrl+n"),
- key.WithHelp("ctrl+n", "new session"),
- ),
- Cancel: key.NewBinding(
- key.WithKeys("esc"),
- key.WithHelp("esc", "cancel"),
- ),
-}
-
-func (p *chatPage) Init() tea.Cmd {
- return p.layout.Init()
-}
-
-func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
- var cmds []tea.Cmd
- switch msg := msg.(type) {
- case tea.WindowSizeMsg:
- logging.Info("Window size changed Chat:", "Width", msg.Width, "Height", msg.Height)
- cmd := p.layout.SetSize(msg.Width, msg.Height)
- cmds = append(cmds, cmd)
- case chat.SendMsg:
- cmd := p.sendMessage(msg.Text, msg.Attachments)
- if cmd != nil {
- return p, cmd
- }
- case commands.CommandRunCustomMsg:
- // Check if the agent is busy before executing custom commands
- if p.app.CoderAgent.IsBusy() {
- return p, util.ReportWarn("Agent is busy, please wait before executing a command...")
- }
-
- // Handle custom command execution
- cmd := p.sendMessage(msg.Content, nil)
- if cmd != nil {
- return p, cmd
- }
- case chat.SessionSelectedMsg:
- if p.session.ID == "" {
- cmd := p.setSidebar()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- }
- p.session = msg
- case tea.KeyPressMsg:
- switch {
- case key.Matches(msg, keyMap.NewSession):
- p.session = session.Session{}
- return p, tea.Batch(
- p.clearSidebar(),
- util.CmdHandler(chat.SessionClearedMsg{}),
- )
- case key.Matches(msg, keyMap.Cancel):
- if p.session.ID != "" {
- // Cancel the current session's generation process
- // This allows users to interrupt long-running operations
- p.app.CoderAgent.Cancel(p.session.ID)
- return p, nil
- }
- }
- }
- u, cmd := p.layout.Update(msg)
- cmds = append(cmds, cmd)
- p.layout = u.(layout.SplitPaneLayout)
-
- return p, tea.Batch(cmds...)
-}
-
-func (p *chatPage) setSidebar() tea.Cmd {
- sidebarContainer := layout.NewContainer(
- chat.NewSidebarCmp(p.session, p.app.History),
- layout.WithPadding(1, 1, 1, 1),
- )
- return tea.Batch(p.layout.SetRightPanel(sidebarContainer), sidebarContainer.Init())
-}
-
-func (p *chatPage) clearSidebar() tea.Cmd {
- return p.layout.ClearRightPanel()
-}
-
-func (p *chatPage) sendMessage(text string, attachments []message.Attachment) tea.Cmd {
- var cmds []tea.Cmd
- if p.session.ID == "" {
- session, err := p.app.Sessions.Create(context.Background(), "New Session")
- if err != nil {
- return util.ReportError(err)
- }
-
- p.session = session
- cmd := p.setSidebar()
- if cmd != nil {
- cmds = append(cmds, cmd)
- }
- cmds = append(cmds, util.CmdHandler(chat.SessionSelectedMsg(session)))
- }
-
- _, err := p.app.CoderAgent.Run(context.Background(), p.session.ID, text, attachments...)
- if err != nil {
- return util.ReportError(err)
- }
- return tea.Batch(cmds...)
-}
-
-func (p *chatPage) SetSize(width, height int) tea.Cmd {
- return p.layout.SetSize(width, height)
-}
-
-func (p *chatPage) GetSize() (int, int) {
- return p.layout.GetSize()
-}
-
-func (p *chatPage) View() tea.View {
- return p.layout.View()
-}
-
-func (p *chatPage) BindingKeys() []key.Binding {
- bindings := layout.KeyMapToSlice(keyMap)
- bindings = append(bindings, p.messages.BindingKeys()...)
- bindings = append(bindings, p.editor.BindingKeys()...)
- return bindings
-}
-
-func NewChatPage(app *app.App) util.Model {
- messagesContainer := layout.NewContainer(
- chat.NewMessagesListCmp(app),
- layout.WithPadding(1, 1, 0, 1),
- )
- editorContainer := layout.NewContainer(
- editor.NewEditorCmp(app),
- layout.WithBorder(true, false, false, false),
- )
- return &chatPage{
- app: app,
- editor: editorContainer,
- messages: messagesContainer,
- layout: layout.NewSplitPane(
- layout.WithLeftPanel(messagesContainer),
- layout.WithBottomPanel(editorContainer),
- ),
- }
-}
@@ -0,0 +1,79 @@
+package styles
+
+import (
+ "github.com/alecthomas/chroma/v2"
+ "github.com/charmbracelet/glamour/v2/ansi"
+)
+
+func chromaStyle(style ansi.StylePrimitive) string {
+ var s string
+
+ if style.Color != nil {
+ s = *style.Color
+ }
+ if style.BackgroundColor != nil {
+ if s != "" {
+ s += " "
+ }
+ s += "bg:" + *style.BackgroundColor
+ }
+ if style.Italic != nil && *style.Italic {
+ if s != "" {
+ s += " "
+ }
+ s += "italic"
+ }
+ if style.Bold != nil && *style.Bold {
+ if s != "" {
+ s += " "
+ }
+ s += "bold"
+ }
+ if style.Underline != nil && *style.Underline {
+ if s != "" {
+ s += " "
+ }
+ s += "underline"
+ }
+
+ return s
+}
+
+func GetChromaTheme() chroma.StyleEntries {
+ t := CurrentTheme()
+ rules := t.S().Markdown.CodeBlock
+
+ return chroma.StyleEntries{
+ chroma.Text: chromaStyle(rules.Chroma.Text),
+ chroma.Error: chromaStyle(rules.Chroma.Error),
+ chroma.Comment: chromaStyle(rules.Chroma.Comment),
+ chroma.CommentPreproc: chromaStyle(rules.Chroma.CommentPreproc),
+ chroma.Keyword: chromaStyle(rules.Chroma.Keyword),
+ chroma.KeywordReserved: chromaStyle(rules.Chroma.KeywordReserved),
+ chroma.KeywordNamespace: chromaStyle(rules.Chroma.KeywordNamespace),
+ chroma.KeywordType: chromaStyle(rules.Chroma.KeywordType),
+ chroma.Operator: chromaStyle(rules.Chroma.Operator),
+ chroma.Punctuation: chromaStyle(rules.Chroma.Punctuation),
+ chroma.Name: chromaStyle(rules.Chroma.Name),
+ chroma.NameBuiltin: chromaStyle(rules.Chroma.NameBuiltin),
+ chroma.NameTag: chromaStyle(rules.Chroma.NameTag),
+ chroma.NameAttribute: chromaStyle(rules.Chroma.NameAttribute),
+ chroma.NameClass: chromaStyle(rules.Chroma.NameClass),
+ chroma.NameConstant: chromaStyle(rules.Chroma.NameConstant),
+ chroma.NameDecorator: chromaStyle(rules.Chroma.NameDecorator),
+ chroma.NameException: chromaStyle(rules.Chroma.NameException),
+ chroma.NameFunction: chromaStyle(rules.Chroma.NameFunction),
+ chroma.NameOther: chromaStyle(rules.Chroma.NameOther),
+ chroma.Literal: chromaStyle(rules.Chroma.Literal),
+ chroma.LiteralNumber: chromaStyle(rules.Chroma.LiteralNumber),
+ chroma.LiteralDate: chromaStyle(rules.Chroma.LiteralDate),
+ chroma.LiteralString: chromaStyle(rules.Chroma.LiteralString),
+ chroma.LiteralStringEscape: chromaStyle(rules.Chroma.LiteralStringEscape),
+ chroma.GenericDeleted: chromaStyle(rules.Chroma.GenericDeleted),
+ chroma.GenericEmph: chromaStyle(rules.Chroma.GenericEmph),
+ chroma.GenericInserted: chromaStyle(rules.Chroma.GenericInserted),
+ chroma.GenericStrong: chromaStyle(rules.Chroma.GenericStrong),
+ chroma.GenericSubheading: chromaStyle(rules.Chroma.GenericSubheading),
+ chroma.Background: chromaStyle(rules.Chroma.Background),
+ }
+}
@@ -1,7 +1,6 @@
package styles
import (
- "github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/x/exp/charmtone"
)
@@ -14,10 +13,6 @@ func NewCrushTheme() *Theme {
Secondary: charmtone.Dolly,
Tertiary: charmtone.Bok,
Accent: charmtone.Zest,
-
- Blue: lipgloss.Color(charmtone.Malibu.Hex()),
- PrimaryLight: charmtone.Hazy,
-
// Backgrounds
BgBase: charmtone.Pepper,
BgSubtle: charmtone.Charcoal,
@@ -40,10 +35,15 @@ func NewCrushTheme() *Theme {
Warning: charmtone.Uni,
Info: charmtone.Malibu,
- // TODO: fix this.
- SyntaxBg: lipgloss.Color("#1C1C1F"),
- SyntaxKeyword: lipgloss.Color("#FF6DFE"),
- SyntaxString: lipgloss.Color("#E8FE96"),
- SyntaxComment: lipgloss.Color("#6B6F85"),
+ // Colors
+ Blue: charmtone.Malibu,
+
+ Green: charmtone.Julep,
+ GreenDark: charmtone.Guac,
+ GreenLight: charmtone.Bok,
+
+ Red: charmtone.Coral,
+ RedDark: charmtone.Sriracha,
+ RedLight: charmtone.Salmon,
}
}
@@ -30,10 +30,6 @@ type Theme struct {
Tertiary color.Color
Accent color.Color
- // Colors
- Blue color.Color
- // TODO: add any others needed
-
BgBase color.Color
BgSubtle color.Color
BgOverlay color.Color
@@ -52,15 +48,40 @@ type Theme struct {
Warning color.Color
Info color.Color
- // TODO: add more syntax colors, maybe just use a chroma theme here.
- SyntaxBg color.Color
- SyntaxKeyword color.Color
- SyntaxString color.Color
- SyntaxComment color.Color
+ // Colors
+ // Blues
+ Blue color.Color
+
+ // Greens
+ Green color.Color
+ GreenDark color.Color
+ GreenLight color.Color
+
+ // Reds
+ Red color.Color
+ RedDark color.Color
+ RedLight color.Color
+
+ // TODO: add any others needed
styles *Styles
}
+type Diff struct {
+ Added color.Color
+ Removed color.Color
+ Context color.Color
+ HunkHeader color.Color
+ HighlightAdded color.Color
+ HighlightRemoved color.Color
+ AddedBg color.Color
+ RemovedBg color.Color
+ ContextBg color.Color
+ LineNumber color.Color
+ AddedLineNumberBg color.Color
+ RemovedLineNumberBg color.Color
+}
+
type Styles struct {
Base lipgloss.Style
SelectedBase lipgloss.Style
@@ -86,6 +107,9 @@ type Styles struct {
// Help
Help help.Styles
+
+ // Diff
+ Diff Diff
}
func (t *Theme) S() *Styles {
@@ -390,6 +414,22 @@ func (t *Theme) buildStyles() *Styles {
FullDesc: base.Foreground(t.FgSubtle),
FullSeparator: base.Foreground(t.Border),
},
+
+ // TODO: Fix this this is bad
+ Diff: Diff{
+ Added: t.Green,
+ Removed: t.Red,
+ Context: t.FgSubtle,
+ HunkHeader: t.FgSubtle,
+ HighlightAdded: t.GreenLight,
+ HighlightRemoved: t.RedLight,
+ AddedBg: t.GreenDark,
+ RemovedBg: t.RedDark,
+ ContextBg: t.BgSubtle,
+ LineNumber: t.FgMuted,
+ AddedLineNumberBg: t.GreenDark,
+ RemovedLineNumberBg: t.RedDark,
+ },
}
}
@@ -11,7 +11,6 @@ import (
"github.com/opencode-ai/opencode/internal/pubsub"
cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
"github.com/opencode-ai/opencode/internal/tui/components/completions"
- "github.com/opencode-ai/opencode/internal/tui/components/core"
"github.com/opencode-ai/opencode/internal/tui/components/core/status"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs"
"github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
@@ -95,7 +94,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Status Messages
case util.InfoMsg, util.ClearStatusMsg:
s, statusCmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, statusCmd)
return a, tea.Batch(cmds...)
@@ -108,7 +107,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case pubsub.Event[logging.LogMessage]:
// Send to the status component
s, statusCmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, statusCmd)
// If the current page is logs, update the logs view
@@ -136,7 +135,7 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, a.handleKeyPressMsg(msg)
}
s, _ := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
updated, cmd := a.pages[a.currentPage].Update(msg)
a.pages[a.currentPage] = updated.(util.Model)
cmds = append(cmds, cmd)
@@ -151,7 +150,7 @@ func (a *appModel) handleWindowResize(msg tea.WindowSizeMsg) tea.Cmd {
// Update status bar
s, cmd := a.status.Update(msg)
- a.status = s.(core.StatusCmp)
+ a.status = s.(status.StatusCmp)
cmds = append(cmds, cmd)
// Update the current page
@@ -285,7 +284,7 @@ func (a *appModel) View() tea.View {
// New creates and initializes a new TUI application model.
func New(app *app.App) tea.Model {
- startPage := page.ChatPage
+ startPage := chat.ChatPage
model := &appModel{
currentPage: startPage,
app: app,
@@ -294,7 +293,7 @@ func New(app *app.App) tea.Model {
keyMap: DefaultKeyMap(),
pages: map[page.PageID]util.Model{
- page.ChatPage: chat.NewChatPage(app),
+ chat.ChatPage: chat.NewChatPage(app),
page.LogsPage: page.NewLogsPage(),
},
@@ -13,8 +13,14 @@
- [x] Sessions dialog
- [ ] Models
- [~] Move sessions and model dialog to the commands
+- [ ] Add sessions shortuct
+- [ ] Add all posible actions to the commands
## Investigate
- [ ] Events when tool error
- [ ] Fancy Spinner
+
+## Messages
+
+- [ ] Fix issue with numbers (padding)