From 4a74863d4aeca5b0fb4284427a21eba09b245342 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 4 Jun 2025 12:45:13 +0200 Subject: [PATCH] cleanup old components --- internal/diff/diff.go | 37 +- internal/highlight/highlight.go | 175 +------ internal/tui/components/anim/anim.go | 18 +- internal/tui/components/chat/chat.go | 538 ++++++++++++++++---- internal/tui/components/chat/list.go | 486 ------------------ internal/tui/components/chat/sidebar.go | 381 -------------- internal/tui/components/core/status.go | 329 ------------ internal/tui/components/util/simple-list.go | 164 ------ internal/tui/page/chat.go | 176 ------- internal/tui/styles/chroma.go | 79 +++ internal/tui/styles/crush.go | 20 +- internal/tui/styles/theme.go | 58 ++- internal/tui/tui.go | 13 +- todos.md | 6 + 14 files changed, 633 insertions(+), 1847 deletions(-) delete mode 100644 internal/tui/components/chat/list.go delete mode 100644 internal/tui/components/chat/sidebar.go delete mode 100644 internal/tui/components/core/status.go delete mode 100644 internal/tui/components/util/simple-list.go delete mode 100644 internal/tui/page/chat.go create mode 100644 internal/tui/styles/chroma.go diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 589d17f232f92f73d28900c1b5bc606ee8a6f822..58545566e9035ed4122e103ed624972fa27ce4f2 100644 --- a/internal/diff/diff.go +++ b/internal/diff/diff.go @@ -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("..."), ), ) } diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go index 98315a152292bd6302dd2e840d450e429abc0ff4..6517357a1b2a789d0f49ab13dbc5a0cc9e92bfed 100644 --- a/internal/highlight/highlight.go +++ b/internal/highlight/highlight.go @@ -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(` - -`, - 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 diff --git a/internal/tui/components/anim/anim.go b/internal/tui/components/anim/anim.go index fa6dabaccb1b4375dcb253c566537840a13b056a..46c4156b02d148f51e8ff03afd7341354de1ad44 100644 --- a/internal/tui/components/anim/anim.go +++ b/internal/tui/components/anim/anim.go @@ -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) diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 52c4daeacd2758a82bbfa87a81f3e3f642c1972f..26d5c1c4af70babc614c4709a4e177ef883eb8b8 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -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) } diff --git a/internal/tui/components/chat/list.go b/internal/tui/components/chat/list.go deleted file mode 100644 index 6fe7b96663bf29d495ac5806f5ffc049c1f1a4bd..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/list.go +++ /dev/null @@ -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) -} diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go deleted file mode 100644 index 5d631364a7402f05e2233d28d244c86a0398a3e7..0000000000000000000000000000000000000000 --- a/internal/tui/components/chat/sidebar.go +++ /dev/null @@ -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, "/") -} diff --git a/internal/tui/components/core/status.go b/internal/tui/components/core/status.go deleted file mode 100644 index 648db2a23fa7b5930b5a5b2dadd9c8c398ca202e..0000000000000000000000000000000000000000 --- a/internal/tui/components/core/status.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/components/util/simple-list.go b/internal/tui/components/util/simple-list.go deleted file mode 100644 index 36df48394e0792d056deab6380d6a1003cbe6b55..0000000000000000000000000000000000000000 --- a/internal/tui/components/util/simple-list.go +++ /dev/null @@ -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, - } -} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go deleted file mode 100644 index 684e95df2509af4a3af2eb6b9146f27935a22d8a..0000000000000000000000000000000000000000 --- a/internal/tui/page/chat.go +++ /dev/null @@ -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), - ), - } -} diff --git a/internal/tui/styles/chroma.go b/internal/tui/styles/chroma.go new file mode 100644 index 0000000000000000000000000000000000000000..b6521bea45ea4972cc25711116ff69f2588dd68f --- /dev/null +++ b/internal/tui/styles/chroma.go @@ -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), + } +} diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 2d9736e0c30485dfa2404bb436ccb7ed1fbe2c63..618e0cb496664d18a01a82e3ffe46a9dd6ea7fdf 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/crush.go @@ -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, } } diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index 4c512acdf0f1fa37ac1da26fc69bf9efbb10eb36..099cef8ef957ee2e45c931bb6323e2630f9f74ce 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -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, + }, } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index f42afd0e9a154e4689644914bebffeefb7329d39..d9d2dc5c728bf775b4c05f7440f32c894e6be0c9 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -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(), }, diff --git a/todos.md b/todos.md index beb2f903f3c90a1b6a079ca5032a56de4a6e5017..b6c3853b6f44c70bf20851efba3496c09c1c641f 100644 --- a/todos.md +++ b/todos.md @@ -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)