initial chat refactor

Kujtim Hoxha created

wip

Change summary

internal/diff/diff.go                        | 246 --------
internal/highlight/highlight.go              | 227 +++++++
internal/tui/components/chat/chat.go         |   1 
internal/tui/components/chat/list_v2.go      | 120 ++++
internal/tui/components/chat/message.go      |  69 --
internal/tui/components/chat/message_v2.go   | 244 ++++++++
internal/tui/components/chat/sidebar.go      |   2 
internal/tui/components/chat/tool_message.go | 365 ++++++++++++
internal/tui/components/core/list/keys.go    |  70 ++
internal/tui/components/core/list/list.go    | 625 ++++++++++++++++++++++
internal/tui/page/chat.go                    |   2 
internal/tui/styles/markdown.go              |   4 
internal/tui/theme/opencode.go               |   7 
13 files changed, 1,667 insertions(+), 315 deletions(-)

Detailed changes

internal/diff/diff.go 🔗

@@ -1,22 +1,17 @@
 package diff
 
 import (
-	"bytes"
 	"fmt"
 	"image/color"
-	"io"
 	"regexp"
 	"strconv"
 	"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/aymanbagabas/go-udiff"
 	"github.com/charmbracelet/lipgloss/v2"
 	"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/sergi/go-diff/diffmatchpatch"
 )
@@ -322,216 +317,6 @@ func pairLines(lines []DiffLine) []linePair {
 // -------------------------------------------------------------------------
 // Syntax Highlighting
 // -------------------------------------------------------------------------
-
-// SyntaxHighlight applies syntax highlighting to text based on file extension
-func SyntaxHighlight(w io.Writer, source, fileName, formatter string, bg color.Color) error {
-	t := theme.CurrentTheme()
-
-	// Determine the language lexer to use
-	l := lexers.Match(fileName)
-	if l == nil {
-		l = lexers.Analyse(source)
-	}
-	if l == nil {
-		l = lexers.Fallback
-	}
-	l = chroma.Coalesce(l)
-
-	// Get the formatter
-	f := formatters.Get(formatter)
-	if f == nil {
-		f = formatters.Fallback
-	}
-
-	// Dynamic theme based on current theme values
-	syntaxThemeXml := fmt.Sprintf(`
-	<style name="opencode-theme">
-	<!-- Base colors -->
-	<entry type="Background" style="bg:%s"/>
-	<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.Background()), // Background
-		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)
-
-	// Modify the style to use the provided background
-	s, err := style.Builder().Transform(
-		func(t chroma.StyleEntry) chroma.StyleEntry {
-			r, g, b, _ := bg.RGBA()
-			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
-			return t
-		},
-	).Build()
-	if err != nil {
-		s = styles.Fallback
-	}
-
-	// Tokenize and format
-	it, err := l.Tokenise(nil, source)
-	if err != nil {
-		return err
-	}
-
-	return f.Format(w, s, it)
-}
-
 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)
@@ -539,12 +324,11 @@ func getColor(c color.Color) string {
 
 // highlightLine applies syntax highlighting to a single line
 func highlightLine(fileName string, line string, bg color.Color) string {
-	var buf bytes.Buffer
-	err := SyntaxHighlight(&buf, line, fileName, "terminal16m", bg)
+	highlighted, err := highlight.SyntaxHighlight(line, fileName, bg)
 	if err != nil {
 		return line
 	}
-	return buf.String()
+	return highlighted
 }
 
 // createStyles generates the lipgloss styles needed for rendering diffs
@@ -561,18 +345,6 @@ func createStyles(t theme.Theme) (removedLineStyle, addedLineStyle, contextLineS
 // Rendering Functions
 // -------------------------------------------------------------------------
 
-func lipglossToHex(color color.Color) string {
-	r, g, b, a := color.RGBA()
-
-	// Scale uint32 values (0-65535) to uint8 (0-255).
-	r8 := uint8(r >> 8)
-	g8 := uint8(g >> 8)
-	b8 := uint8(b >> 8)
-	a8 := uint8(a >> 8)
-
-	return fmt.Sprintf("#%02x%02x%02x%02x", r8, g8, b8, a8)
-}
-
 // applyHighlighting applies intra-line highlighting to a piece of text
 func applyHighlighting(content string, segments []Segment, segmentType LineType, highlightBg color.Color) string {
 	// Find all ANSI sequences in the content
@@ -614,7 +386,7 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 
 	// Get the appropriate color based on terminal background
 	bgColor := lipgloss.Color(getColor(highlightBg))
-	fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
+	// fgColor := lipgloss.Color(getColor(theme.CurrentTheme().Background()))
 
 	for i := 0; i < len(content); {
 		// Check if we're at an ANSI sequence
@@ -651,15 +423,15 @@ func applyHighlighting(content string, segments []Segment, segmentType LineType,
 			currentStyle := ansiSequences[currentPos]
 
 			// Apply foreground and background highlight
-			sb.WriteString("\x1b[38;2;")
-			r, g, b, _ := fgColor.RGBA()
-			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
+			// sb.WriteString("\x1b[38;2;")
+			// r, g, b, _ := fgColor.RGBA()
+			// sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
 			sb.WriteString("\x1b[48;2;")
-			r, g, b, _ = bgColor.RGBA()
+			r, g, b, _ := bgColor.RGBA()
 			sb.WriteString(fmt.Sprintf("%d;%d;%dm", r>>8, g>>8, b>>8))
 			sb.WriteString(char)
 			// Reset foreground and background
-			sb.WriteString("\x1b[39m")
+			// sb.WriteString("\x1b[39m")
 
 			// Reapply the original ANSI sequence
 			sb.WriteString(currentStyle)

internal/highlight/highlight.go 🔗

@@ -0,0 +1,227 @@
+package highlight
+
+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"
+)
+
+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 {
+		l = lexers.Analyse(source)
+	}
+	if l == nil {
+		l = lexers.Fallback
+	}
+	l = chroma.Coalesce(l)
+
+	// Get the formatter
+	f := formatters.Get("terminal16m")
+	if f == nil {
+		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)
+
+	// Modify the style to use the provided background
+	s, err := style.Builder().Transform(
+		func(t chroma.StyleEntry) chroma.StyleEntry {
+			r, g, b, _ := bg.RGBA()
+			t.Background = chroma.NewColour(uint8(r>>8), uint8(g>>8), uint8(b>>8))
+			return t
+		},
+	).Build()
+	if err != nil {
+		s = styles.Fallback
+	}
+
+	// Tokenize and format
+	it, err := l.Tokenise(nil, source)
+	if err != nil {
+		return "", err
+	}
+
+	var buf bytes.Buffer
+	err = f.Format(&buf, s, it)
+	return buf.String(), err
+}
+
+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)
+}

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

@@ -0,0 +1,120 @@
+package chat
+
+import (
+	"context"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/opencode-ai/opencode/internal/app"
+	"github.com/opencode-ai/opencode/internal/message"
+	"github.com/opencode-ai/opencode/internal/session"
+	"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 MessageListCmp interface {
+	util.Model
+	layout.Sizeable
+}
+
+type messageListCmp struct {
+	app           *app.App
+	width, height int
+	session       session.Session
+	messages      []util.Model
+	listCmp       list.ListModel
+}
+
+func NewMessagesListCmp(app *app.App) MessageListCmp {
+	return &messageListCmp{
+		app: app,
+		listCmp: list.New(
+			list.WithGapSize(1),
+			list.WithReverse(true),
+		),
+	}
+}
+
+func (m *messageListCmp) Init() tea.Cmd {
+	return nil
+}
+
+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
+	}
+	return m, nil
+}
+
+func (m *messageListCmp) View() string {
+	return m.listCmp.View()
+}
+
+// GetSize implements MessageListCmp.
+func (m *messageListCmp) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+// SetSize implements MessageListCmp.
+func (m *messageListCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	m.height = height
+	return m.listCmp.SetSize(width, height)
+}
+
+func (m *messageListCmp) SetSession(session session.Session) tea.Cmd {
+	if m.session.ID == session.ID {
+		return nil
+	}
+	m.session = session
+	messages, err := m.app.Messages.List(context.Background(), session.ID)
+	if err != nil {
+		return util.ReportError(err)
+	}
+	m.messages = make([]util.Model, 0)
+	lastUserMessageTime := messages[0].CreatedAt
+	toolResultMap := make(map[string]message.ToolResult)
+	// first pass to get all tool results
+	for _, msg := range messages {
+		for _, tr := range msg.ToolResults() {
+			toolResultMap[tr.ToolCallID] = tr
+		}
+	}
+	for _, msg := range messages {
+		// TODO: handle tool calls and others here
+		switch msg.Role {
+		case message.User:
+			lastUserMessageTime = msg.CreatedAt
+			m.messages = append(m.messages, NewMessageCmp(WithMessage(msg)))
+		case message.Assistant:
+			// Only add assistant messages if they don't have tool calls or there is some content
+			if len(msg.ToolCalls()) == 0 || msg.Content().Text != "" || msg.IsThinking() {
+				m.messages = append(m.messages, NewMessageCmp(WithMessage(msg), WithLastUserMessageTime(time.Unix(lastUserMessageTime, 0))))
+			}
+			for _, tc := range msg.ToolCalls() {
+				options := []MessageOption{
+					WithToolCall(tc),
+				}
+				if tr, ok := toolResultMap[tc.ID]; ok {
+					options = append(options, WithToolResult(tr))
+				}
+				if msg.FinishPart().Reason == message.FinishReasonCanceled {
+					options = append(options, WithCancelledToolCall(true))
+				}
+				m.messages = append(m.messages, NewMessageCmp(options...))
+			}
+		}
+	}
+	m.listCmp.SetItems(m.messages)
+	return nil
+}

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

@@ -10,7 +10,6 @@ import (
 
 	"github.com/charmbracelet/lipgloss/v2"
 	"github.com/charmbracelet/x/ansi"
-	"github.com/opencode-ai/opencode/internal/config"
 	"github.com/opencode-ai/opencode/internal/diff"
 	"github.com/opencode-ai/opencode/internal/llm/agent"
 	"github.com/opencode-ai/opencode/internal/llm/models"
@@ -272,66 +271,6 @@ func getToolAction(name string) string {
 	return "Working..."
 }
 
-// renders params, params[0] (params[1]=params[2] ....)
-func renderParams(paramsWidth int, params ...string) string {
-	if len(params) == 0 {
-		return ""
-	}
-	mainParam := params[0]
-	if len(mainParam) > paramsWidth {
-		mainParam = mainParam[:paramsWidth-3] + "..."
-	}
-
-	if len(params) == 1 {
-		return mainParam
-	}
-	otherParams := params[1:]
-	// create pairs of key/value
-	// if odd number of params, the last one is a key without value
-	if len(otherParams)%2 != 0 {
-		otherParams = append(otherParams, "")
-	}
-	parts := make([]string, 0, len(otherParams)/2)
-	for i := 0; i < len(otherParams); i += 2 {
-		key := otherParams[i]
-		value := otherParams[i+1]
-		if value == "" {
-			continue
-		}
-		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
-	}
-
-	partsRendered := strings.Join(parts, ", ")
-	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
-	if remainingWidth < 30 {
-		// No space for the params, just show the main
-		return mainParam
-	}
-
-	if len(parts) > 0 {
-		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
-	}
-
-	return ansi.Truncate(mainParam, paramsWidth, "...")
-}
-
-func removeWorkingDirPrefix(path string) string {
-	wd := config.WorkingDirectory()
-	if strings.HasPrefix(path, wd) {
-		path = strings.TrimPrefix(path, wd)
-	}
-	if strings.HasPrefix(path, "/") {
-		path = strings.TrimPrefix(path, "/")
-	}
-	if strings.HasPrefix(path, "./") {
-		path = strings.TrimPrefix(path, "./")
-	}
-	if strings.HasPrefix(path, "../") {
-		path = strings.TrimPrefix(path, "../")
-	}
-	return path
-}
-
 func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
 	params := ""
 	switch toolCall.Name {
@@ -430,14 +369,6 @@ func renderToolParams(paramWidth int, toolCall message.ToolCall) string {
 	return params
 }
 
-func truncateHeight(content string, height int) string {
-	lines := strings.Split(content, "\n")
-	if len(lines) > height {
-		return strings.Join(lines[:height], "\n")
-	}
-	return content
-}
-
 func renderToolResponse(toolCall message.ToolCall, response message.ToolResult, width int) string {
 	t := theme.CurrentTheme()
 	baseStyle := styles.BaseStyle()

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

@@ -0,0 +1,244 @@
+package chat
+
+import (
+	"fmt"
+	"image/color"
+	"path/filepath"
+	"strings"
+	"time"
+
+	tea "github.com/charmbracelet/bubbletea/v2"
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/opencode-ai/opencode/internal/llm/models"
+
+	"github.com/opencode-ai/opencode/internal/message"
+	"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 MessageCmp interface {
+	util.Model
+	layout.Sizeable
+	layout.Focusable
+}
+
+type messageCmp struct {
+	width   int
+	focused bool
+
+	// Used for agent and user messages
+	message             message.Message
+	lastUserMessageTime time.Time
+
+	// Used for tool calls
+	toolCall          message.ToolCall
+	toolResult        message.ToolResult
+	cancelledToolCall bool
+}
+
+type MessageOption func(*messageCmp)
+
+func WithLastUserMessageTime(t time.Time) MessageOption {
+	return func(m *messageCmp) {
+		m.lastUserMessageTime = t
+	}
+}
+
+func WithToolCall(tc message.ToolCall) MessageOption {
+	return func(m *messageCmp) {
+		m.toolCall = tc
+	}
+}
+
+func WithToolResult(tr message.ToolResult) MessageOption {
+	return func(m *messageCmp) {
+		m.toolResult = tr
+	}
+}
+
+func WithMessage(msg message.Message) MessageOption {
+	return func(m *messageCmp) {
+		m.message = msg
+	}
+}
+
+func WithCancelledToolCall(cancelled bool) MessageOption {
+	return func(m *messageCmp) {
+		m.cancelledToolCall = cancelled
+	}
+}
+
+func NewMessageCmp(opts ...MessageOption) MessageCmp {
+	m := &messageCmp{}
+	for _, opt := range opts {
+		opt(m)
+	}
+	return m
+}
+
+func (m *messageCmp) Init() tea.Cmd {
+	return nil
+}
+
+func (m *messageCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	return m, nil
+}
+
+func (m *messageCmp) View() string {
+	if m.message.ID != "" {
+		// this is a user or assistant message
+		switch m.message.Role {
+		case message.User:
+			return m.renderUserMessage()
+		default:
+			return m.renderAssistantMessage()
+		}
+	} else if m.toolCall.ID != "" {
+		// this is a tool call message
+		return m.renderToolCallMessage()
+	}
+	return "Unknown Message"
+}
+
+func (m *messageCmp) textWidth() int {
+	if m.toolCall.ID != "" {
+		return m.width - 2 // take into account the border and PaddingLeft
+	}
+	return m.width - 1 // take into account the border
+}
+
+func (msg *messageCmp) style() lipgloss.Style {
+	t := theme.CurrentTheme()
+	var borderColor color.Color
+	borderStyle := lipgloss.NormalBorder()
+	if msg.focused {
+		borderStyle = lipgloss.DoubleBorder()
+	}
+
+	switch msg.message.Role {
+	case message.User:
+		borderColor = t.Secondary()
+	case message.Assistant:
+		borderColor = t.Primary()
+	default:
+		// Tool call
+		borderColor = t.TextMuted()
+	}
+
+	return styles.BaseStyle().
+		BorderLeft(true).
+		Foreground(t.TextMuted()).
+		BorderForeground(borderColor).
+		BorderStyle(borderStyle)
+}
+
+func (m *messageCmp) renderAssistantMessage() string {
+	parts := []string{
+		m.markdownContent(),
+	}
+
+	finished := m.message.IsFinished()
+	finishData := m.message.FinishPart()
+	// Only show the footer if the message is not a tool call
+	if finished && finishData.Reason != message.FinishReasonToolUse {
+		infoMsg := ""
+		switch finishData.Reason {
+		case message.FinishReasonEndTurn:
+			finishTime := time.Unix(finishData.Time, 0)
+			duration := finishTime.Sub(m.lastUserMessageTime)
+			infoMsg = duration.String()
+		case message.FinishReasonCanceled:
+			infoMsg = "canceled"
+		case message.FinishReasonError:
+			infoMsg = "error"
+		case message.FinishReasonPermissionDenied:
+			infoMsg = "permission denied"
+		}
+		parts = append(parts, fmt.Sprintf(" %s (%s)", models.SupportedModels[m.message.Model].Name, infoMsg))
+	}
+
+	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	return m.style().Render(joined)
+}
+
+func (m *messageCmp) renderUserMessage() string {
+	t := theme.CurrentTheme()
+	parts := []string{
+		m.markdownContent(),
+	}
+	attachmentStyles := styles.BaseStyle().
+		MarginLeft(1).
+		Background(t.BackgroundSecondary()).
+		Foreground(t.Text())
+	attachments := []string{}
+	for _, attachment := range m.message.BinaryContent() {
+		file := filepath.Base(attachment.Path)
+		var filename string
+		if len(file) > 10 {
+			filename = fmt.Sprintf(" %s %s... ", styles.DocumentIcon, file[0:7])
+		} else {
+			filename = fmt.Sprintf(" %s %s ", styles.DocumentIcon, file)
+		}
+		attachments = append(attachments, attachmentStyles.Render(filename))
+	}
+	if len(attachments) > 0 {
+		parts = append(parts, "", strings.Join(attachments, ""))
+	}
+	joined := lipgloss.JoinVertical(lipgloss.Left, parts...)
+	return m.style().Render(joined)
+}
+
+func (m *messageCmp) toMarkdown(content string) string {
+	r := styles.GetMarkdownRenderer(m.textWidth())
+	rendered, _ := r.Render(content)
+	return strings.TrimSuffix(rendered, "\n")
+}
+
+func (m *messageCmp) markdownContent() string {
+	content := m.message.Content().String()
+	if m.message.Role == message.Assistant {
+		thinking := m.message.IsThinking()
+		finished := m.message.IsFinished()
+		finishedData := m.message.FinishPart()
+		if thinking {
+			// Handle the thinking state
+			// TODO: maybe add the thinking content if available later.
+			content = fmt.Sprintf("**%s %s**", styles.LoadingIcon, "Thinking...")
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonEndTurn {
+			// Sometimes the LLMs respond with no content when they think the previous tool result
+			//  provides the requested question
+			content = "*Finished without output*"
+		} else if finished && content == "" && finishedData.Reason == message.FinishReasonCanceled {
+			content = "*Canceled*"
+		}
+	}
+	return m.toMarkdown(content)
+}
+
+// Blur implements MessageModel.
+func (m *messageCmp) Blur() tea.Cmd {
+	m.focused = false
+	return nil
+}
+
+// Focus implements MessageModel.
+func (m *messageCmp) Focus() tea.Cmd {
+	m.focused = true
+	return nil
+}
+
+// IsFocused implements MessageModel.
+func (m *messageCmp) IsFocused() bool {
+	return m.focused
+}
+
+func (m *messageCmp) GetSize() (int, int) {
+	return m.width, 0
+}
+
+func (m *messageCmp) SetSize(width int, height int) tea.Cmd {
+	m.width = width
+	return nil
+}

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

@@ -181,7 +181,7 @@ func (m *sidebarCmp) modifiedFiles() string {
 		Render("Modified Files:")
 
 	// If no modified files, show a placeholder message
-	if m.modFiles == nil || len(m.modFiles) == 0 {
+	if len(m.modFiles) == 0 {
 		message := "No modified files"
 		remainingWidth := m.width - lipgloss.Width(message)
 		if remainingWidth > 0 {

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

@@ -0,0 +1,365 @@
+package chat
+
+import (
+	"encoding/json"
+	"fmt"
+	"strings"
+
+	"github.com/charmbracelet/lipgloss/v2"
+	"github.com/charmbracelet/x/ansi"
+	"github.com/opencode-ai/opencode/internal/config"
+	"github.com/opencode-ai/opencode/internal/diff"
+	"github.com/opencode-ai/opencode/internal/highlight"
+	"github.com/opencode-ai/opencode/internal/llm/agent"
+	"github.com/opencode-ai/opencode/internal/llm/tools"
+	"github.com/opencode-ai/opencode/internal/tui/styles"
+	"github.com/opencode-ai/opencode/internal/tui/theme"
+)
+
+const responseContextHeight = 10
+
+func (m *messageCmp) renderUnfinishedToolCall() string {
+	toolName := m.toolName()
+	toolAction := m.getToolAction()
+	return fmt.Sprintf("%s: %s", toolName, toolAction)
+}
+
+func (m *messageCmp) renderToolError() string {
+	t := theme.CurrentTheme()
+	baseStyle := styles.BaseStyle()
+	err := strings.ReplaceAll(m.toolResult.Content, "\n", " ")
+	err = fmt.Sprintf("Error: %s", err)
+	return baseStyle.Foreground(t.Error()).Render(m.fit(err))
+}
+
+func (m *messageCmp) renderBashTool() string {
+	name := m.toolName()
+	prefix := fmt.Sprintf("%s: ", name)
+	var params tools.BashParams
+	json.Unmarshal([]byte(m.toolCall.Input), &params)
+	command := strings.ReplaceAll(params.Command, "\n", " ")
+	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), command)
+
+	if result, ok := m.toolResultErrorOrMissing(header); ok {
+		return result
+	}
+	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
+}
+
+func (m *messageCmp) renderViewTool() string {
+	name := m.toolName()
+	prefix := fmt.Sprintf("%s: ", name)
+	var params tools.ViewParams
+	json.Unmarshal([]byte(m.toolCall.Input), &params)
+	filePath := removeWorkingDirPrefix(params.FilePath)
+	toolParams := []string{
+		filePath,
+	}
+	if params.Limit != 0 {
+		toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+	}
+	if params.Offset != 0 {
+		toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+	}
+	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), toolParams...)
+
+	if result, ok := m.toolResultErrorOrMissing(header); ok {
+		return result
+	}
+
+	metadata := tools.ViewResponseMetadata{}
+	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
+
+	return m.renderTool(header, m.renderCodeContent(metadata.FilePath, metadata.Content, params.Offset))
+}
+
+func (m *messageCmp) renderCodeContent(path, content string, offset int) string {
+	t := theme.CurrentTheme()
+	originalHeight := lipgloss.Height(content)
+	fileContent := truncateHeight(content, responseContextHeight)
+
+	highlighted, _ := highlight.SyntaxHighlight(fileContent, path, t.BackgroundSecondary())
+
+	lines := strings.Split(highlighted, "\n")
+
+	if originalHeight > responseContextHeight {
+		lines = append(lines,
+			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
+				Foreground(t.TextMuted()).
+				Render(
+					fmt.Sprintf("... (%d lines)", originalHeight-responseContextHeight),
+				),
+		)
+	}
+	for i, line := range lines {
+		lineNumber := lipgloss.NewStyle().
+			PaddingLeft(4).
+			PaddingRight(2).
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(fmt.Sprintf("%d", i+1+offset))
+		formattedLine := lipgloss.NewStyle().
+			Width(m.textWidth() - lipgloss.Width(lineNumber)).
+			Background(t.BackgroundSecondary()).Render(line)
+		lines[i] = lipgloss.JoinHorizontal(lipgloss.Left, lineNumber, formattedLine)
+	}
+	return lipgloss.NewStyle().Render(
+		lipgloss.JoinVertical(
+			lipgloss.Left,
+			lines...,
+		),
+	)
+}
+
+func (m *messageCmp) renderPlainContent(content string) string {
+	t := theme.CurrentTheme()
+	content = strings.TrimSuffix(content, "\n")
+	content = strings.TrimPrefix(content, "\n")
+	lines := strings.Split(fmt.Sprintf("\n%s\n", content), "\n")
+
+	for i, line := range lines {
+		line = " " + line // add padding
+		if len(line) > m.textWidth() {
+			line = m.fit(line)
+		}
+		lines[i] = lipgloss.NewStyle().
+			Width(m.textWidth()).
+			Background(t.BackgroundSecondary()).
+			Foreground(t.TextMuted()).
+			Render(line)
+	}
+	if len(lines) > responseContextHeight {
+		lines = lines[:responseContextHeight]
+		lines = append(lines,
+			lipgloss.NewStyle().Background(t.BackgroundSecondary()).
+				Foreground(t.TextMuted()).
+				Render(
+					fmt.Sprintf("... (%d lines)", len(lines)-responseContextHeight),
+				),
+		)
+	}
+	return strings.Join(lines, "\n")
+}
+
+func (m *messageCmp) renderGenericTool() string {
+	// Tool params
+	name := m.toolName()
+	prefix := fmt.Sprintf("%s: ", name)
+	input := strings.ReplaceAll(m.toolCall.Input, "\n", " ")
+	params := renderParams(m.textWidth()-lipgloss.Width(prefix), input)
+	header := prefix + params
+
+	if result, ok := m.toolResultErrorOrMissing(header); ok {
+		return result
+	}
+	return m.renderTool(header, m.renderPlainContent(m.toolResult.Content))
+}
+
+func (m *messageCmp) renderEditTool() string {
+	// Tool params
+	name := m.toolName()
+	prefix := fmt.Sprintf("%s: ", name)
+	var params tools.EditParams
+	json.Unmarshal([]byte(m.toolCall.Input), &params)
+	filePath := removeWorkingDirPrefix(params.FilePath)
+	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
+
+	if result, ok := m.toolResultErrorOrMissing(header); ok {
+		return result
+	}
+	metadata := tools.EditResponseMetadata{}
+	json.Unmarshal([]byte(m.toolResult.Metadata), &metadata)
+	truncDiff := truncateHeight(metadata.Diff, maxResultHeight)
+	formattedDiff, _ := diff.FormatDiff(truncDiff, diff.WithTotalWidth(m.textWidth()))
+	return m.renderTool(header, formattedDiff)
+}
+
+func (m *messageCmp) renderWriteTool() string {
+	// Tool params
+	name := m.toolName()
+	prefix := fmt.Sprintf("%s: ", name)
+	var params tools.WriteParams
+	json.Unmarshal([]byte(m.toolCall.Input), &params)
+	filePath := removeWorkingDirPrefix(params.FilePath)
+	header := prefix + renderParams(m.textWidth()-lipgloss.Width(prefix), filePath)
+	if result, ok := m.toolResultErrorOrMissing(header); ok {
+		return result
+	}
+	return m.renderTool(header, m.renderCodeContent(filePath, params.Content, 0))
+}
+
+func (m *messageCmp) renderToolCallMessage() string {
+	if !m.toolCall.Finished && !m.cancelledToolCall {
+		return m.renderUnfinishedToolCall()
+	}
+	content := ""
+	switch m.toolCall.Name {
+	case tools.ViewToolName:
+		content = m.renderViewTool()
+	case tools.BashToolName:
+		content = m.renderBashTool()
+	case tools.EditToolName:
+		content = m.renderEditTool()
+	case tools.WriteToolName:
+		content = m.renderWriteTool()
+	default:
+		content = m.renderGenericTool()
+	}
+	return m.style().PaddingLeft(1).Render(content)
+}
+
+func (m *messageCmp) toolResultErrorOrMissing(header string) (string, bool) {
+	result := "Waiting for tool to finish..."
+	if m.toolResult.IsError {
+		result = m.renderToolError()
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			header,
+			result,
+		), true
+	} else if m.cancelledToolCall {
+		result = "Cancelled"
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			header,
+			result,
+		), true
+	} else if m.toolResult.ToolCallID == "" {
+		return lipgloss.JoinVertical(
+			lipgloss.Left,
+			header,
+			result,
+		), true
+	}
+
+	return "", false
+}
+
+func (m *messageCmp) renderTool(header, result string) string {
+	return lipgloss.JoinVertical(
+		lipgloss.Left,
+		header,
+		"",
+		result,
+		"",
+	)
+}
+
+func removeWorkingDirPrefix(path string) string {
+	wd := config.WorkingDirectory()
+	path = strings.TrimPrefix(path, wd)
+	return path
+}
+
+func truncateHeight(content string, height int) string {
+	lines := strings.Split(content, "\n")
+	if len(lines) > height {
+		return strings.Join(lines[:height], "\n")
+	}
+	return content
+}
+
+func (m *messageCmp) fit(content string) string {
+	return ansi.Truncate(content, m.textWidth(), "...")
+}
+
+func (m *messageCmp) toolName() string {
+	switch m.toolCall.Name {
+	case agent.AgentToolName:
+		return "Task"
+	case tools.BashToolName:
+		return "Bash"
+	case tools.EditToolName:
+		return "Edit"
+	case tools.FetchToolName:
+		return "Fetch"
+	case tools.GlobToolName:
+		return "Glob"
+	case tools.GrepToolName:
+		return "Grep"
+	case tools.LSToolName:
+		return "List"
+	case tools.SourcegraphToolName:
+		return "Sourcegraph"
+	case tools.ViewToolName:
+		return "View"
+	case tools.WriteToolName:
+		return "Write"
+	case tools.PatchToolName:
+		return "Patch"
+	default:
+		return m.toolCall.Name
+	}
+}
+
+func (m *messageCmp) getToolAction() string {
+	switch m.toolCall.Name {
+	case agent.AgentToolName:
+		return "Preparing prompt..."
+	case tools.BashToolName:
+		return "Building command..."
+	case tools.EditToolName:
+		return "Preparing edit..."
+	case tools.FetchToolName:
+		return "Writing fetch..."
+	case tools.GlobToolName:
+		return "Finding files..."
+	case tools.GrepToolName:
+		return "Searching content..."
+	case tools.LSToolName:
+		return "Listing directory..."
+	case tools.SourcegraphToolName:
+		return "Searching code..."
+	case tools.ViewToolName:
+		return "Reading file..."
+	case tools.WriteToolName:
+		return "Preparing write..."
+	case tools.PatchToolName:
+		return "Preparing patch..."
+	default:
+		return "Working..."
+	}
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+	if len(params) == 0 {
+		return ""
+	}
+	mainParam := params[0]
+	if len(mainParam) > paramsWidth {
+		mainParam = mainParam[:paramsWidth-3] + "..."
+	}
+
+	if len(params) == 1 {
+		return mainParam
+	}
+	otherParams := params[1:]
+	// create pairs of key/value
+	// if odd number of params, the last one is a key without value
+	if len(otherParams)%2 != 0 {
+		otherParams = append(otherParams, "")
+	}
+	parts := make([]string, 0, len(otherParams)/2)
+	for i := 0; i < len(otherParams); i += 2 {
+		key := otherParams[i]
+		value := otherParams[i+1]
+		if value == "" {
+			continue
+		}
+		parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+	}
+
+	partsRendered := strings.Join(parts, ", ")
+	remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 3 // count for " ()"
+	if remainingWidth < 30 {
+		// No space for the params, just show the main
+		return mainParam
+	}
+
+	if len(parts) > 0 {
+		mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+	}
+
+	return ansi.Truncate(mainParam, paramsWidth, "...")
+}

internal/tui/components/core/list/keys.go 🔗

@@ -0,0 +1,70 @@
+package list
+
+import "github.com/charmbracelet/bubbles/v2/key"
+
+type KeyMap struct {
+	Down,
+	Up,
+	NDown,
+	NUp,
+	DownOneItem,
+	UpOneItem,
+	HalfPageDown,
+	HalfPageUp,
+	Home,
+	End,
+	Submit key.Binding
+}
+
+func defaultKeymap() KeyMap {
+	return KeyMap{
+		Down: key.NewBinding(
+			key.WithKeys("down", "ctrl+j", "ctrl+n"),
+		),
+		Up: key.NewBinding(
+			key.WithKeys("up", "ctrl+k", "ctrl+p"),
+		),
+		NDown: key.NewBinding(
+			key.WithKeys("j"),
+		),
+		NUp: key.NewBinding(
+			key.WithKeys("k"),
+		),
+		UpOneItem: key.NewBinding(
+			key.WithKeys("shift+up"),
+		),
+		DownOneItem: key.NewBinding(
+			key.WithKeys("shift+down"),
+		),
+		HalfPageDown: key.NewBinding(
+			key.WithKeys("ctrl+d"),
+		),
+		HalfPageUp: key.NewBinding(
+			key.WithKeys("ctrl+u"),
+		),
+		Home: key.NewBinding(
+			key.WithKeys("g", "home"),
+		),
+		End: key.NewBinding(
+			key.WithKeys("shift+g", "end"),
+		),
+		Submit: key.NewBinding(
+			key.WithKeys("enter", "space"),
+			key.WithHelp("enter/space", "select"),
+		),
+	}
+}
+
+// FullHelp implements help.KeyMap.
+func (k KeyMap) FullHelp() [][]key.Binding { return nil }
+
+// ShortHelp implements help.KeyMap.
+func (k KeyMap) ShortHelp() []key.Binding {
+	return []key.Binding{
+		key.NewBinding(
+			key.WithKeys("up", "down"),
+			key.WithHelp("↓↑", "navigate"),
+		),
+		k.Submit,
+	}
+}

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

@@ -0,0 +1,625 @@
+package list
+
+import (
+	"slices"
+	"strings"
+	"sync"
+
+	"github.com/charmbracelet/bubbles/v2/help"
+	"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/util"
+)
+
+type ListModel interface {
+	util.Model
+	layout.Sizeable
+	SetItems([]util.Model) tea.Cmd
+	AppendItem(util.Model)
+	PrependItem(util.Model)
+	DeleteItem(int)
+	UpdateItem(int, util.Model)
+	ResetView()
+}
+
+type renderedItem struct {
+	lines  []string
+	start  int
+	height int
+}
+type model struct {
+	width, height, offset int
+	finalHight            int // this gets set when the last item is rendered to mark the max offset
+	reverse               bool
+	help                  help.Model
+	keymap                KeyMap
+	items                 []util.Model
+	renderedItems         *sync.Map // item index to rendered string
+	needsRerender         bool
+	renderedLines         []string
+	selectedItemInx       int
+	lastRenderedInx       int
+	content               string
+	gapSize               int
+	padding               []int
+}
+
+type listOptions func(*model)
+
+func WithKeyMap(k KeyMap) listOptions {
+	return func(m *model) {
+		m.keymap = k
+	}
+}
+
+func WithReverse(reverse bool) listOptions {
+	return func(m *model) {
+		m.setReverse(reverse)
+	}
+}
+
+func WithGapSize(gapSize int) listOptions {
+	return func(m *model) {
+		m.gapSize = gapSize
+	}
+}
+
+func WithPadding(padding ...int) listOptions {
+	return func(m *model) {
+		m.padding = padding
+	}
+}
+
+func WithItems(items []util.Model) listOptions {
+	return func(m *model) {
+		m.items = items
+	}
+}
+
+func New(opts ...listOptions) ListModel {
+	m := &model{
+		help:            help.New(),
+		keymap:          defaultKeymap(),
+		items:           []util.Model{},
+		needsRerender:   true,
+		gapSize:         0,
+		padding:         []int{},
+		selectedItemInx: -1,
+		finalHight:      -1,
+		lastRenderedInx: -1,
+		renderedItems:   new(sync.Map),
+	}
+	for _, opt := range opts {
+		opt(m)
+	}
+	return m
+}
+
+// Init implements List.
+func (m *model) Init() tea.Cmd {
+	cmds := []tea.Cmd{
+		m.SetItems(m.items),
+	}
+	return tea.Batch(cmds...)
+}
+
+// Update implements List.
+func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+	switch msg := msg.(type) {
+	case tea.KeyMsg:
+		switch {
+		case key.Matches(msg, m.keymap.Down) || key.Matches(msg, m.keymap.NDown):
+			if m.reverse {
+				m.decreaseOffset(1)
+			} else {
+				m.increaseOffset(1)
+			}
+			return m, nil
+		case key.Matches(msg, m.keymap.Up) || key.Matches(msg, m.keymap.NUp):
+			if m.reverse {
+				m.increaseOffset(1)
+			} else {
+				m.decreaseOffset(1)
+			}
+			return m, nil
+		case key.Matches(msg, m.keymap.DownOneItem):
+			m.downOneItem()
+			return m, nil
+		case key.Matches(msg, m.keymap.UpOneItem):
+			m.upOneItem()
+			return m, nil
+		case key.Matches(msg, m.keymap.HalfPageDown):
+			if m.reverse {
+				m.decreaseOffset(m.listHeight() / 2)
+			} else {
+				m.increaseOffset(m.listHeight() / 2)
+			}
+			return m, nil
+		case key.Matches(msg, m.keymap.HalfPageUp):
+			if m.reverse {
+				m.increaseOffset(m.listHeight() / 2)
+			} else {
+				m.decreaseOffset(m.listHeight() / 2)
+			}
+			return m, nil
+		case key.Matches(msg, m.keymap.Home):
+			m.goToTop()
+			return m, nil
+		case key.Matches(msg, m.keymap.End):
+			m.goToBottom()
+			return m, nil
+		}
+	}
+	if m.selectedItemInx > -1 {
+		u, cmd := m.items[m.selectedItemInx].Update(msg)
+		m.UpdateItem(m.selectedItemInx, u.(util.Model))
+		return m, cmd
+	}
+
+	return m, nil
+}
+
+// View implements List.
+func (m *model) View() string {
+	if m.height == 0 || m.width == 0 {
+		return ""
+	}
+	if m.needsRerender {
+		m.renderVisible()
+	}
+	return lipgloss.NewStyle().Padding(m.padding...).Render(m.content)
+}
+
+func (m *model) renderVisibleReverse() {
+	start := 0
+	cutoff := m.offset + m.listHeight()
+	items := m.items
+	if m.lastRenderedInx > -1 {
+		items = m.items[:m.lastRenderedInx]
+		start = len(m.renderedLines)
+	} else {
+		// reveresed so that it starts at the end
+		m.lastRenderedInx = len(m.items)
+	}
+	realIndex := m.lastRenderedInx
+	for i := len(items) - 1; i >= 0; i-- {
+		realIndex--
+		var itemLines []string
+		cachedContent, ok := m.renderedItems.Load(realIndex)
+		if ok {
+			itemLines = cachedContent.(renderedItem).lines
+		} else {
+			itemLines = strings.Split(items[i].View(), "\n")
+			if m.gapSize > 0 && realIndex != len(m.items)-1 {
+				for range m.gapSize {
+					itemLines = append(itemLines, "")
+				}
+			}
+			m.renderedItems.Store(realIndex, renderedItem{
+				lines:  itemLines,
+				start:  start,
+				height: len(itemLines),
+			})
+		}
+
+		if realIndex == 0 {
+			m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+		}
+		m.renderedLines = append(itemLines, m.renderedLines...)
+		m.lastRenderedInx = realIndex
+		// always render the next item
+		if start > cutoff {
+			break
+		}
+		start += len(itemLines)
+	}
+	m.needsRerender = false
+	if m.finalHight > -1 {
+		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
+		m.offset = min(m.offset, m.finalHight)
+	}
+	maxHeight := min(m.listHeight(), len(m.renderedLines))
+	if m.offset < len(m.renderedLines) {
+		end := len(m.renderedLines) - m.offset
+		start := max(0, end-maxHeight)
+		m.content = strings.Join(m.renderedLines[start:end], "\n")
+	} else {
+		m.content = ""
+	}
+}
+
+func (m *model) renderVisible() {
+	if m.reverse {
+		m.renderVisibleReverse()
+		return
+	}
+	start := 0
+	cutoff := m.offset + m.listHeight()
+	items := m.items
+	if m.lastRenderedInx > -1 {
+		items = m.items[m.lastRenderedInx+1:]
+		start = len(m.renderedLines)
+	}
+
+	realIndex := m.lastRenderedInx
+	for _, item := range items {
+		realIndex++
+
+		var itemLines []string
+		cachedContent, ok := m.renderedItems.Load(realIndex)
+		if ok {
+			itemLines = cachedContent.(renderedItem).lines
+		} else {
+			itemLines = strings.Split(item.View(), "\n")
+			if m.gapSize > 0 && realIndex != len(m.items)-1 {
+				for range m.gapSize {
+					itemLines = append(itemLines, "")
+				}
+			}
+			m.renderedItems.Store(realIndex, renderedItem{
+				lines:  itemLines,
+				start:  start,
+				height: len(itemLines),
+			})
+		}
+		// always render the next item
+		if start > cutoff {
+			break
+		}
+
+		if realIndex == len(m.items)-1 {
+			m.finalHight = max(0, start+len(itemLines)-m.listHeight())
+		}
+
+		m.renderedLines = append(m.renderedLines, itemLines...)
+		m.lastRenderedInx = realIndex
+		start += len(itemLines)
+	}
+	m.needsRerender = false
+	maxHeight := min(m.listHeight(), len(m.renderedLines))
+	if m.finalHight > -1 {
+		// make sure we don't go over the final height, this can happen if we did not render the last item and we overshot the offset
+		m.offset = min(m.offset, m.finalHight)
+	}
+	if m.offset < len(m.renderedLines) {
+		m.content = strings.Join(m.renderedLines[m.offset:maxHeight+m.offset], "\n")
+	} else {
+		m.content = ""
+	}
+}
+
+func (m *model) upOneItem() tea.Cmd {
+	var cmds []tea.Cmd
+	if m.selectedItemInx > 0 {
+		cmd := m.blurSelected()
+		cmds = append(cmds, cmd)
+		m.selectedItemInx--
+		cmd = m.focusSelected()
+		cmds = append(cmds, cmd)
+	}
+
+	cached, ok := m.renderedItems.Load(m.selectedItemInx)
+	if ok {
+		// already rendered
+		if !m.reverse {
+			cachedItem, _ := cached.(renderedItem)
+			// might not fit on the screen move the offset to the start of the item
+			if cachedItem.height >= m.listHeight() {
+				changeNeeded := m.offset - cachedItem.start
+				m.decreaseOffset(changeNeeded)
+			}
+			if cachedItem.start < m.offset {
+				changeNeeded := m.offset - cachedItem.start
+				m.decreaseOffset(changeNeeded)
+			}
+		} else {
+			cachedItem, _ := cached.(renderedItem)
+			// might not fit on the screen move the offset to the start of the item
+			if cachedItem.height >= m.listHeight() || cachedItem.start+cachedItem.height > m.offset+m.listHeight() {
+				changeNeeded := (cachedItem.start + cachedItem.height - m.listHeight()) - m.offset
+				m.increaseOffset(changeNeeded)
+			}
+		}
+	}
+	m.needsRerender = true
+	return tea.Batch(cmds...)
+}
+
+func (m *model) downOneItem() tea.Cmd {
+	var cmds []tea.Cmd
+	if m.selectedItemInx < len(m.items)-1 {
+		cmd := m.blurSelected()
+		cmds = append(cmds, cmd)
+		m.selectedItemInx++
+		cmd = m.focusSelected()
+		cmds = append(cmds, cmd)
+	}
+	cached, ok := m.renderedItems.Load(m.selectedItemInx)
+	if ok {
+		// already rendered
+		if !m.reverse {
+			cachedItem, _ := cached.(renderedItem)
+			// might not fit on the screen move the offset to the start of the item
+			if cachedItem.height >= m.listHeight() {
+				changeNeeded := cachedItem.start - m.offset
+				m.increaseOffset(changeNeeded)
+			} else {
+				end := cachedItem.start + cachedItem.height
+				if end > m.offset+m.listHeight() {
+					changeNeeded := end - (m.offset + m.listHeight())
+					m.increaseOffset(changeNeeded)
+				}
+			}
+		} else {
+			cachedItem, _ := cached.(renderedItem)
+			// might not fit on the screen move the offset to the start of the item
+			if cachedItem.height >= m.listHeight() {
+				changeNeeded := m.offset - (cachedItem.start + cachedItem.height - m.listHeight())
+				m.decreaseOffset(changeNeeded)
+			} else {
+				if cachedItem.start < m.offset {
+					changeNeeded := m.offset - cachedItem.start
+					m.decreaseOffset(changeNeeded)
+				}
+			}
+		}
+	}
+
+	m.needsRerender = true
+	return tea.Batch(cmds...)
+}
+
+func (m *model) goToBottom() tea.Cmd {
+	var cmds []tea.Cmd
+	m.reverse = true
+	cmd := m.blurSelected()
+	cmds = append(cmds, cmd)
+	m.selectedItemInx = len(m.items) - 1
+	cmd = m.focusSelected()
+	cmds = append(cmds, cmd)
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+func (m *model) ResetView() {
+	m.renderedItems.Clear()
+	m.renderedLines = []string{}
+	m.offset = 0
+	m.lastRenderedInx = -1
+	m.finalHight = -1
+	m.needsRerender = true
+}
+
+func (m *model) goToTop() tea.Cmd {
+	var cmds []tea.Cmd
+	m.reverse = false
+	cmd := m.blurSelected()
+	cmds = append(cmds, cmd)
+	m.selectedItemInx = 0
+	cmd = m.focusSelected()
+	cmds = append(cmds, cmd)
+	m.ResetView()
+	return tea.Batch(cmds...)
+}
+
+func (m *model) focusSelected() tea.Cmd {
+	if m.selectedItemInx == -1 {
+		return nil
+	}
+	if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+		cmd := i.Focus()
+		m.rerenderItem(m.selectedItemInx)
+		return cmd
+	}
+	return nil
+}
+
+func (m *model) blurSelected() tea.Cmd {
+	if m.selectedItemInx == -1 {
+		return nil
+	}
+	if i, ok := m.items[m.selectedItemInx].(layout.Focusable); ok {
+		cmd := i.Blur()
+		m.rerenderItem(m.selectedItemInx)
+		return cmd
+	}
+	return nil
+}
+
+func (m *model) rerenderItem(inx int) {
+	if inx < 0 || len(m.renderedLines) == 0 {
+		return
+	}
+	cached, ok := m.renderedItems.Load(inx)
+	cachedItem, _ := cached.(renderedItem)
+	if !ok {
+		// No need to rerender
+		return
+	}
+	rerenderedItem := m.items[inx].View()
+	rerenderedLines := strings.Split(rerenderedItem, "\n")
+	if m.gapSize > 0 && inx != len(m.items)-1 {
+		for range m.gapSize {
+			rerenderedLines = append(rerenderedLines, "")
+		}
+	}
+	// check if lines are the same
+	if slices.Equal(cachedItem.lines, rerenderedLines) {
+		// No changes
+		return
+	}
+	// check if the item is in the content
+	start := cachedItem.start
+	end := start + cachedItem.height
+	totalLines := len(m.renderedLines)
+	if m.reverse {
+		end = totalLines - cachedItem.start
+		start = end - cachedItem.height
+	}
+	if start <= totalLines && end <= totalLines {
+		m.renderedLines = slices.Delete(m.renderedLines, start, end)
+		m.renderedLines = slices.Insert(m.renderedLines, start, rerenderedLines...)
+	}
+	// TODO: if hight changed do something
+	if cachedItem.height != len(rerenderedLines) && inx != len(m.items)-1 {
+		panic("not handled")
+	}
+	m.renderedItems.Store(inx, renderedItem{
+		lines:  rerenderedLines,
+		start:  cachedItem.start,
+		height: len(rerenderedLines),
+	})
+	m.needsRerender = true
+}
+
+func (m *model) increaseOffset(n int) {
+	if m.finalHight > -1 {
+		if m.offset < m.finalHight {
+			m.offset += n
+			if m.offset > m.finalHight {
+				m.offset = m.finalHight
+			}
+			m.needsRerender = true
+		}
+	} else {
+		m.offset += n
+		m.needsRerender = true
+	}
+}
+
+func (m *model) decreaseOffset(n int) {
+	if m.offset > 0 {
+		m.offset -= n
+		if m.offset < 0 {
+			m.offset = 0
+		}
+		m.needsRerender = true
+	}
+}
+
+// UpdateItem implements List.
+func (m *model) UpdateItem(inx int, item util.Model) {
+	m.items[inx] = item
+	m.rerenderItem(inx)
+	m.needsRerender = true
+}
+
+// GetSize implements List.
+func (m *model) GetSize() (int, int) {
+	return m.width, m.height
+}
+
+// SetSize implements List.
+func (m *model) SetSize(width int, height int) tea.Cmd {
+	if m.width == width && m.height == height {
+		return nil
+	}
+	if m.height != height {
+		m.finalHight = -1
+		m.height = height
+	}
+	m.width = width
+	m.ResetView()
+	return m.setItemsSize()
+}
+
+func (m *model) setItemsSize() tea.Cmd {
+	var cmds []tea.Cmd
+	width := m.width
+	if m.padding != nil {
+		if len(m.padding) == 1 {
+			width -= m.padding[0] * 2
+		} else if len(m.padding) == 2 || len(m.padding) == 3 {
+			width -= m.padding[1] * 2
+		} else if len(m.padding) == 4 {
+			width -= m.padding[1] + m.padding[3]
+		}
+	}
+	for _, item := range m.items {
+		if i, ok := item.(layout.Sizeable); ok {
+			cmd := i.SetSize(width, 0) // height is not limited
+			cmds = append(cmds, cmd)
+		}
+	}
+	return tea.Batch(cmds...)
+}
+
+func (m *model) listHeight() int {
+	height := m.height
+	if m.padding != nil {
+		if len(m.padding) == 1 {
+			height -= m.padding[0] * 2
+		} else if len(m.padding) == 2 {
+			height -= m.padding[1] * 2
+		} else if len(m.padding) == 3 {
+			height -= m.padding[0] + m.padding[2]
+		} else if len(m.padding) == 4 {
+			height -= m.padding[0] + m.padding[2]
+		}
+	}
+	return height
+}
+
+// AppendItem implements List.
+func (m *model) AppendItem(item util.Model) {
+	m.items = append(m.items, item)
+	m.goToBottom()
+	m.needsRerender = true
+}
+
+// DeleteItem implements List.
+func (m *model) DeleteItem(i int) {
+	m.items = slices.Delete(m.items, i, i+1)
+	m.renderedItems.Delete(i)
+	m.needsRerender = true
+}
+
+// PrependItem implements List.
+func (m *model) PrependItem(item util.Model) {
+	m.items = append([]util.Model{item}, m.items...)
+	// update the indices of the rendered items
+	newRenderedItems := make(map[int]renderedItem)
+	m.renderedItems.Range(func(key any, value any) bool {
+		keyInt := key.(int)
+		renderedItem := value.(renderedItem)
+		newKey := keyInt + 1
+		newRenderedItems[newKey] = renderedItem
+		return false
+	})
+	m.renderedItems.Clear()
+	for k, v := range newRenderedItems {
+		m.renderedItems.Store(k, v)
+	}
+	m.goToTop()
+	m.needsRerender = true
+}
+
+func (m *model) setReverse(reverse bool) {
+	if reverse {
+		m.goToBottom()
+	} else {
+		m.goToTop()
+	}
+}
+
+// SetItems implements List.
+func (m *model) SetItems(items []util.Model) tea.Cmd {
+	m.items = items
+	var cmds []tea.Cmd
+	cmd := m.setItemsSize()
+	cmds = append(cmds, cmd)
+	if m.reverse {
+		m.selectedItemInx = len(m.items) - 1
+		cmd := m.focusSelected()
+		cmds = append(cmds, cmd)
+	} else {
+		m.selectedItemInx = 0
+		cmd := m.focusSelected()
+		cmds = append(cmds, cmd)
+	}
+	m.needsRerender = true
+	m.ResetView()
+	return tea.Batch(cmds...)
+}

internal/tui/page/chat.go 🔗

@@ -217,7 +217,7 @@ func NewChatPage(app *app.App) util.Model {
 	completionDialog := dialog.NewCompletionDialogCmp(cg)
 
 	messagesContainer := layout.NewContainer(
-		chat.NewMessagesCmp(app),
+		chat.NewMessagesListCmp(app),
 		layout.WithPadding(1, 1, 0, 1),
 	)
 	editorContainer := layout.NewContainer(

internal/tui/styles/markdown.go 🔗

@@ -33,9 +33,7 @@ func generateMarkdownStyleConfig() ansi.StyleConfig {
 	return ansi.StyleConfig{
 		Document: ansi.StyleBlock{
 			StylePrimitive: ansi.StylePrimitive{
-				BlockPrefix: "",
-				BlockSuffix: "",
-				Color:       stringPtr(colorToString(t.MarkdownText())),
+				Color: stringPtr(colorToString(t.MarkdownText())),
 			},
 			Margin: uintPtr(defaultMargin),
 		},

internal/tui/theme/opencode.go 🔗

@@ -62,8 +62,9 @@ func NewOpenCodeDarkTheme() *OpenCodeTheme {
 	theme.DiffRemovedColor = lipgloss.Color("#7C4444")
 	theme.DiffContextColor = lipgloss.Color("#a0a0a0")
 	theme.DiffHunkHeaderColor = lipgloss.Color("#a0a0a0")
-	theme.DiffHighlightAddedColor = lipgloss.Color("#DAFADA")
-	theme.DiffHighlightRemovedColor = lipgloss.Color("#FADADD")
+	// TODO: change these colors to be what we want
+	theme.DiffHighlightAddedColor = lipgloss.Color("#256125")
+	theme.DiffHighlightRemovedColor = lipgloss.Color("#612726")
 	theme.DiffAddedBgColor = lipgloss.Color("#303A30")
 	theme.DiffRemovedBgColor = lipgloss.Color("#3A3030")
 	theme.DiffContextBgColor = lipgloss.Color(darkBackground)
@@ -195,4 +196,4 @@ func init() {
 	// Register the OpenCode themes with the theme manager
 	RegisterTheme("opencode-dark", NewOpenCodeDarkTheme())
 	RegisterTheme("opencode-light", NewOpenCodeLightTheme())
-}
+}