Detailed changes
@@ -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)
@@ -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)
+}
@@ -138,4 +138,3 @@ func cwd(width int) string {
Width(width).
Render(cwd)
}
-
@@ -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
+}
@@ -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()
@@ -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
+}
@@ -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 {
@@ -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), ¶ms)
+ 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), ¶ms)
+ 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), ¶ms)
+ 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), ¶ms)
+ 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, "...")
+}
@@ -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,
+ }
+}
@@ -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...)
+}
@@ -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(
@@ -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),
},
@@ -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())
-}
+}