cleanup old components

Kujtim Hoxha created

Change summary

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(+), 1,847 deletions(-)

Detailed changes

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("..."),
 		),
 	)
 }

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(`
-	<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

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)

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)
 }

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

@@ -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)
-}

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

@@ -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, "/")
-}

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

@@ -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,
-	}
-}

internal/tui/components/util/simple-list.go 🔗

@@ -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,
-	}
-}

internal/tui/page/chat.go 🔗

@@ -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),
-		),
-	}
-}

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),
+	}
+}

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,
 	}
 }

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,
+		},
 	}
 }
 

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(),
 		},
 

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)