diff --git a/internal/diff/diff.go b/internal/diff/diff.go index 6dcafa984cd052102807e8454e7bfe047cf08d5d..9e5e8ae43a31f7df945befca3f505563d0e67919 100644 --- a/internal/diff/diff.go +++ b/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(` - -`, - 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) diff --git a/internal/highlight/highlight.go b/internal/highlight/highlight.go new file mode 100644 index 0000000000000000000000000000000000000000..98315a152292bd6302dd2e840d450e429abc0ff4 --- /dev/null +++ b/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(` + +`, + 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) +} diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go index 2aa7ee5d07f324ca45e80dc4fbab9964f05721bb..f7ccea001e1b03e08f16170c1d86db13729853e4 100644 --- a/internal/tui/components/chat/chat.go +++ b/internal/tui/components/chat/chat.go @@ -138,4 +138,3 @@ func cwd(width int) string { Width(width). Render(cwd) } - diff --git a/internal/tui/components/chat/list_v2.go b/internal/tui/components/chat/list_v2.go new file mode 100644 index 0000000000000000000000000000000000000000..10010ab8b635f7ecfc265163e07d1c87b984e187 --- /dev/null +++ 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 +} diff --git a/internal/tui/components/chat/message.go b/internal/tui/components/chat/message.go index f1fdda7265cb1697f10d68cde45c9d0563ecbed3..96a33da9150cd135d006007a0d659217c261f738 100644 --- a/internal/tui/components/chat/message.go +++ b/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() diff --git a/internal/tui/components/chat/message_v2.go b/internal/tui/components/chat/message_v2.go new file mode 100644 index 0000000000000000000000000000000000000000..1e281ec01b2fe3c72dd1a6faf62da4a685404348 --- /dev/null +++ b/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 +} diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index b54769038c508894f0fa289aadcba9e82b3f189f..75e87335d27d83200e3b3ba9c39274bc658a4bc3 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/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 { diff --git a/internal/tui/components/chat/tool_message.go b/internal/tui/components/chat/tool_message.go new file mode 100644 index 0000000000000000000000000000000000000000..60333c2e6d3ab3fb70c670fa07b573c84b8fa37c --- /dev/null +++ b/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), ¶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, "...") +} diff --git a/internal/tui/components/core/list/keys.go b/internal/tui/components/core/list/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..1c26ef26764bb09d1e7219ccc8f7cb4fe29b0b80 --- /dev/null +++ b/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, + } +} diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go new file mode 100644 index 0000000000000000000000000000000000000000..4ed851ce546ed39f6b829877f4f25b55a862d091 --- /dev/null +++ b/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...) +} diff --git a/internal/tui/page/chat.go b/internal/tui/page/chat.go index 7096d7d159e2f86c37d26fc74d36b2249cd72f6f..9ba3ebac1701d8446222dc7f8f704de3166823c4 100644 --- a/internal/tui/page/chat.go +++ b/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( diff --git a/internal/tui/styles/markdown.go b/internal/tui/styles/markdown.go index bf9114d35ad9b2d36f742deb622a64210772bf20..39ab57d14785222dbcf88116bd62060513c01ec4 100644 --- a/internal/tui/styles/markdown.go +++ b/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), }, diff --git a/internal/tui/theme/opencode.go b/internal/tui/theme/opencode.go index e4a3af7b3208e3a1c475b2333043e65c4264b3e6..40bbaeca95066615e8abc3c9e8984e8f5f530a9d 100644 --- a/internal/tui/theme/opencode.go +++ b/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()) -} \ No newline at end of file +}