wip: refactoring tui

This commit is contained in:
adamdottv
2025-06-04 09:20:42 -05:00
parent 0b565b18c4
commit 01050a430f
60 changed files with 115 additions and 115 deletions

View File

@@ -0,0 +1,131 @@
package chat
import (
"fmt"
"sort"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type SendMsg struct {
Text string
Attachments []app.Attachment
}
func header(app *app.App, width int) string {
return lipgloss.JoinVertical(
lipgloss.Top,
logo(width),
repo(width),
"",
cwd(app, width),
)
}
func lspsConfigured(width int) string {
// cfg := config.Get()
title := "LSP Servers"
title = ansi.Truncate(title, width, "…")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
lsps := baseStyle.
Width(width).
Foreground(t.Primary()).
Bold(true).
Render(title)
// Get LSP names and sort them for consistent ordering
var lspNames []string
// for name := range cfg.LSP {
// lspNames = append(lspNames, name)
// }
sort.Strings(lspNames)
var lspViews []string
// for _, name := range lspNames {
// lsp := cfg.LSP[name]
// lspName := baseStyle.
// Foreground(t.Text()).
// Render(fmt.Sprintf("• %s", name))
// cmd := lsp.Command
// cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
// lspPath := baseStyle.
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf(" (%s)", cmd))
// lspViews = append(lspViews,
// baseStyle.
// Width(width).
// Render(
// lipgloss.JoinHorizontal(
// lipgloss.Left,
// lspName,
// lspPath,
// ),
// ),
// )
// }
return baseStyle.
Width(width).
Render(
lipgloss.JoinVertical(
lipgloss.Left,
lsps,
lipgloss.JoinVertical(
lipgloss.Left,
lspViews...,
),
),
)
}
func logo(width int) string {
logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
versionText := baseStyle.
Foreground(t.TextMuted()).
Render("v0.0.1") // TODO: get version from server
return baseStyle.
Bold(true).
Width(width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
logo,
" ",
versionText,
),
)
}
func repo(width int) string {
repo := "github.com/sst/opencode"
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(repo)
}
func cwd(app *app.App, width int) string {
cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
t := theme.CurrentTheme()
return styles.BaseStyle().
Foreground(t.TextMuted()).
Width(width).
Render(cwd)
}

View File

@@ -0,0 +1,406 @@
package chat
import (
"fmt"
"log/slog"
"os"
"os/exec"
"slices"
"strings"
"unicode"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textarea"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/image"
"github.com/sst/opencode/internal/layout"
"github.com/sst/opencode/internal/status"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/internal/util"
)
type editorCmp struct {
width int
height int
app *app.App
textarea textarea.Model
attachments []app.Attachment
deleteMode bool
history []string
historyIndex int
currentMessage string
}
type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}
type bluredEditorKeyMaps struct {
Send key.Binding
Focus key.Binding
OpenEditor key.Binding
}
type DeleteAttachmentKeyMaps struct {
AttachmentDeleteMode key.Binding
Escape key.Binding
DeleteAllAttachments key.Binding
}
var editorMaps = EditorKeyMaps{
Send: key.NewBinding(
key.WithKeys("enter", "ctrl+s"),
key.WithHelp("enter", "send message"),
),
OpenEditor: key.NewBinding(
key.WithKeys("ctrl+e"),
key.WithHelp("ctrl+e", "open editor"),
),
Paste: key.NewBinding(
key.WithKeys("ctrl+v"),
key.WithHelp("ctrl+v", "paste content"),
),
HistoryUp: key.NewBinding(
key.WithKeys("up"),
key.WithHelp("up", "previous message"),
),
HistoryDown: key.NewBinding(
key.WithKeys("down"),
key.WithHelp("down", "next message"),
),
}
var DeleteKeyMaps = DeleteAttachmentKeyMaps{
AttachmentDeleteMode: key.NewBinding(
key.WithKeys("ctrl+r"),
key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
),
Escape: key.NewBinding(
key.WithKeys("esc"),
key.WithHelp("esc", "cancel delete mode"),
),
DeleteAllAttachments: key.NewBinding(
key.WithKeys("r"),
key.WithHelp("ctrl+r+r", "delete all attachments"),
),
}
const (
maxAttachments = 5
)
func (m *editorCmp) openEditor(value string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = "nvim"
}
tmpfile, err := os.CreateTemp("", "msg_*.md")
tmpfile.WriteString(value)
if err != nil {
status.Error(err.Error())
return nil
}
tmpfile.Close()
c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
return tea.ExecProcess(c, func(err error) tea.Msg {
if err != nil {
status.Error(err.Error())
return nil
}
content, err := os.ReadFile(tmpfile.Name())
if err != nil {
status.Error(err.Error())
return nil
}
if len(content) == 0 {
status.Warn("Message is empty")
return nil
}
os.Remove(tmpfile.Name())
attachments := m.attachments
m.attachments = nil
return SendMsg{
Text: string(content),
Attachments: attachments,
}
})
}
func (m *editorCmp) Init() tea.Cmd {
return textarea.Blink
}
func (m *editorCmp) send() tea.Cmd {
value := m.textarea.Value()
m.textarea.Reset()
attachments := m.attachments
// Save to history if not empty and not a duplicate of the last entry
if value != "" {
if len(m.history) == 0 || m.history[len(m.history)-1] != value {
m.history = append(m.history, value)
}
m.historyIndex = len(m.history)
m.currentMessage = ""
}
m.attachments = nil
if value == "" {
return nil
}
return tea.Batch(
util.CmdHandler(SendMsg{
Text: value,
Attachments: attachments,
}),
)
}
func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.textarea = CreateTextArea(&m.textarea)
case dialog.CompletionSelectedMsg:
existingValue := m.textarea.Value()
modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
m.textarea.SetValue(modifiedValue)
return m, nil
case dialog.AttachmentAddedMsg:
if len(m.attachments) >= maxAttachments {
status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
return m, cmd
}
m.attachments = append(m.attachments, msg.Attachment)
case tea.KeyMsg:
if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
m.deleteMode = true
return m, nil
}
if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
m.deleteMode = false
m.attachments = nil
return m, nil
}
if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
num := int(msg.Runes[0] - '0')
m.deleteMode = false
if num < 10 && len(m.attachments) > num {
if num == 0 {
m.attachments = m.attachments[num+1:]
} else {
m.attachments = slices.Delete(m.attachments, num, num+1)
}
return m, nil
}
}
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
return m, nil
}
if key.Matches(msg, editorMaps.OpenEditor) {
// if m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID) {
// status.Warn("Agent is working, please wait...")
// return m, nil
// }
value := m.textarea.Value()
m.textarea.Reset()
return m, m.openEditor(value)
}
if key.Matches(msg, DeleteKeyMaps.Escape) {
m.deleteMode = false
return m, nil
}
if key.Matches(msg, editorMaps.Paste) {
imageBytes, text, err := image.GetImageFromClipboard()
if err != nil {
slog.Error(err.Error())
return m, cmd
}
if len(imageBytes) != 0 {
attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
m.attachments = append(m.attachments, attachment)
} else {
m.textarea.SetValue(m.textarea.Value() + text)
}
return m, cmd
}
// Handle history navigation with up/down arrow keys
// Only handle history navigation if the filepicker is not open and completion dialog is not open
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()
// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}
// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
m.textarea.SetValue(m.history[m.historyIndex])
}
return m, nil
}
}
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)
// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
// Go to next message in history
m.historyIndex++
m.textarea.SetValue(m.history[m.historyIndex])
} else if m.historyIndex == len(m.history)-1 {
// Return to the current message being composed
m.historyIndex = len(m.history)
m.textarea.SetValue(m.currentMessage)
}
return m, nil
}
}
// Handle Enter key
if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
value := m.textarea.Value()
if len(value) > 0 && value[len(value)-1] == '\\' {
// If the last character is a backslash, remove it and add a newline
m.textarea.SetValue(value[:len(value)-1] + "\n")
return m, nil
} else {
// Otherwise, send the message
return m, m.send()
}
}
}
m.textarea, cmd = m.textarea.Update(msg)
return m, cmd
}
func (m *editorCmp) View() string {
t := theme.CurrentTheme()
// Style the prompt with theme colors
style := lipgloss.NewStyle().
Padding(0, 0, 0, 1).
Bold(true).
Foreground(t.Primary())
if len(m.attachments) == 0 {
return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
}
m.textarea.SetHeight(m.height - 1)
return lipgloss.JoinVertical(lipgloss.Top,
m.attachmentsContent(),
lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
m.textarea.View()),
)
}
func (m *editorCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
m.textarea.SetWidth(width - 3) // account for the prompt and padding right
m.textarea.SetHeight(height)
return nil
}
func (m *editorCmp) GetSize() (int, int) {
return m.textarea.Width(), m.textarea.Height()
}
func (m *editorCmp) attachmentsContent() string {
var styledAttachments []string
t := theme.CurrentTheme()
attachmentStyles := styles.BaseStyle().
MarginLeft(1).
Background(t.TextMuted()).
Foreground(t.Text())
for i, attachment := range m.attachments {
var filename string
if len(attachment.FileName) > 10 {
filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
} else {
filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
}
if m.deleteMode {
filename = fmt.Sprintf("%d%s", i, filename)
}
styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
}
content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
return content
}
func (m *editorCmp) BindingKeys() []key.Binding {
bindings := []key.Binding{}
bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
return bindings
}
func CreateTextArea(existing *textarea.Model) textarea.Model {
t := theme.CurrentTheme()
bgColor := t.Background()
textColor := t.Text()
textMutedColor := t.TextMuted()
ta := textarea.New()
ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
ta.Prompt = " "
ta.ShowLineNumbers = false
ta.CharLimit = -1
if existing != nil {
ta.SetValue(existing.Value())
ta.SetWidth(existing.Width())
ta.SetHeight(existing.Height())
}
ta.Focus()
return ta
}
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}

View File

@@ -0,0 +1,643 @@
package chat
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/sst/opencode/internal/components/diff"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
const (
maxResultHeight = 10
)
func toMarkdown(content string, width int) string {
r := styles.GetMarkdownRenderer(width)
rendered, _ := r.Render(content)
return strings.TrimSuffix(rendered, "\n")
}
func renderUserMessage(user string, msg client.MessageInfo, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Secondary()).
BorderStyle(lipgloss.ThickBorder())
// var styledAttachments []string
// attachmentStyles := baseStyle.
// MarginLeft(1).
// Background(t.TextMuted()).
// Foreground(t.Text())
// for _, attachment := range msg.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)
// }
// styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
// }
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
info := styles.Padded().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", user, timestamp))
content := ""
// if len(styledAttachments) > 0 {
// attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
// content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
// } else {
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
}
}
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func convertToMap(input *any) (map[string]any, bool) {
if input == nil {
return nil, false // Handle nil pointer
}
value := *input // Dereference the pointer to get the interface value
m, ok := value.(map[string]any) // Type assertion
return m, ok
}
func renderAssistantMessage(
msg client.MessageInfo,
width int,
showToolMessages bool,
) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.Primary()).
BorderStyle(lipgloss.ThickBorder())
messages := []string{}
timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
if time.Now().Format("02 Jan 2006") == timestamp[:11] {
timestamp = timestamp[12:]
}
modelName := msg.Metadata.Assistant.ModelID
info := styles.Padded().
Foreground(t.TextMuted()).
Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
for _, p := range msg.Parts {
part, err := p.ValueByDiscriminator()
if err != nil {
continue //TODO: handle error?
}
switch part.(type) {
// case client.MessagePartReasoning:
// reasoningPart := part.(client.MessagePartReasoning)
case client.MessagePartText:
textPart := part.(client.MessagePartText)
text := toMarkdown(textPart.Text, width)
content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
messages = append(messages, message)
case client.MessagePartToolInvocation:
if !showToolMessages {
continue
}
toolInvocationPart := part.(client.MessagePartToolInvocation)
toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
var result *string
resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
if resultError == nil {
result = &resultPart.Result
}
metadata := map[string]any{}
if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
}
message := renderToolInvocation(toolCall, result, metadata, width)
messages = append(messages, message)
}
}
return strings.Join(messages, "\n\n")
}
func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, width int) string {
t := theme.CurrentTheme()
style := styles.BaseStyle().
BorderLeft(true).
Foreground(t.TextMuted()).
BorderForeground(t.TextMuted()).
BorderStyle(lipgloss.ThickBorder())
toolName := renderToolName(toolCall.ToolName)
var toolArgs []string
toolMap, _ := convertToMap(toolCall.Args)
for _, arg := range toolMap {
toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
}
params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
finished := result != nil
body := styles.Padded().Render("In progress...")
if finished {
body = *result
}
if toolCall.ToolName == "opencode_edit" {
filename := toolMap["filePath"].(string)
title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename))
// oldString := toolMap["oldString"].(string)
// newString := toolMap["newString"].(string)
if finished {
patch := metadata["diff"].(string)
formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
body = strings.TrimSpace(formattedDiff)
}
return style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
} else if toolCall.ToolName == "opencode_view" {
filename := toolMap["filePath"].(string)
ext := filepath.Ext(filename)
if ext == "" {
ext = ""
} else {
ext = strings.ToLower(ext[1:])
}
if finished {
if metadata["preview"] != nil {
body = metadata["preview"].(string)
}
body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
body = toMarkdown(body, width)
}
content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
// Default rendering
if finished {
body = styles.Padded().Render(truncateHeight(strings.TrimSpace(body), 10))
body = toMarkdown(body, width)
}
content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
title,
body,
))
return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
}
func renderToolName(name string) string {
switch name {
// case agent.AgentToolName:
// return "Task"
case "opencode_ls":
return "List"
default:
normalizedName := name
if strings.HasPrefix(name, "opencode_") {
normalizedName = strings.TrimPrefix(name, "opencode_")
}
return cases.Title(language.Und).String(normalizedName)
}
}
func renderToolAction(name string) string {
switch name {
// case agent.AgentToolName:
// return "Preparing prompt..."
case "opencode_bash":
return "Building command..."
case "opencode_edit":
return "Preparing edit..."
case "opencode_fetch":
return "Writing fetch..."
case "opencode_glob":
return "Finding files..."
case "opencode_grep":
return "Searching content..."
case "opencode_ls":
return "Listing directory..."
case "opencode_view":
return "Reading file..."
case "opencode_write":
return "Preparing write..."
case "opencode_patch":
return "Preparing patch..."
case "opencode_batch":
return "Running batch operations..."
}
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 renderToolParams(paramWidth int, toolCall any) string {
params := ""
switch toolCall {
// // case agent.AgentToolName:
// // var params agent.AgentParams
// // json.Unmarshal([]byte(toolCall.Input), &params)
// // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
// // return renderParams(paramWidth, prompt)
// case "bash":
// var params tools.BashParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// command := strings.ReplaceAll(params.Command, "\n", " ")
// return renderParams(paramWidth, command)
// case "edit":
// var params tools.EditParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case "fetch":
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// url := params.URL
// toolParams := []string{
// url,
// }
// if params.Format != "" {
// toolParams = append(toolParams, "format", params.Format)
// }
// if params.Timeout != 0 {
// toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GlobToolName:
// var params tools.GlobParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// return renderParams(paramWidth, toolParams...)
// case tools.GrepToolName:
// var params tools.GrepParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// pattern := params.Pattern
// toolParams := []string{
// pattern,
// }
// if params.Path != "" {
// toolParams = append(toolParams, "path", params.Path)
// }
// if params.Include != "" {
// toolParams = append(toolParams, "include", params.Include)
// }
// if params.LiteralText {
// toolParams = append(toolParams, "literal", "true")
// }
// return renderParams(paramWidth, toolParams...)
// case tools.LSToolName:
// var params tools.LSParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// path := params.Path
// if path == "" {
// path = "."
// }
// return renderParams(paramWidth, path)
// case tools.ViewToolName:
// var params tools.ViewParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// toolParams := []string{
// filePath,
// }
// if params.Limit != 0 {
// toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
// }
// if params.Offset != 0 {
// toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
// }
// return renderParams(paramWidth, toolParams...)
// case tools.WriteToolName:
// var params tools.WriteParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// filePath := removeWorkingDirPrefix(params.FilePath)
// return renderParams(paramWidth, filePath)
// case tools.BatchToolName:
// var params tools.BatchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
// default:
// input := strings.ReplaceAll(toolCall, "\n", " ")
// params = renderParams(paramWidth, input)
}
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 any, response any, width int) string {
return ""
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// if response.IsError {
// errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
// errContent = ansi.Truncate(errContent, width-1, "...")
// return baseStyle.
// Width(width).
// Foreground(t.Error()).
// Render(errContent)
// }
//
// resultContent := truncateHeight(response.Content, maxResultHeight)
// switch toolCall.Name {
// case agent.AgentToolName:
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, false, width),
// t.Background(),
// )
// case tools.BashToolName:
// resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.EditToolName:
// metadata := tools.EditResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
// return formattedDiff
// case tools.FetchToolName:
// var params tools.FetchParams
// json.Unmarshal([]byte(toolCall.Input), &params)
// mdFormat := "markdown"
// switch params.Format {
// case "text":
// mdFormat = "text"
// case "html":
// mdFormat = "html"
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.GlobToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.GrepToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.LSToolName:
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
// case tools.ViewToolName:
// metadata := tools.ViewResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(metadata.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.WriteToolName:
// params := tools.WriteParams{}
// json.Unmarshal([]byte(toolCall.Input), &params)
// metadata := tools.WriteResponseMetadata{}
// json.Unmarshal([]byte(response.Metadata), &metadata)
// ext := filepath.Ext(params.FilePath)
// if ext == "" {
// ext = ""
// } else {
// ext = strings.ToLower(ext[1:])
// }
// resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// case tools.BatchToolName:
// var batchResult tools.BatchResult
// if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
// return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
// }
//
// var toolCalls []string
// for i, result := range batchResult.Results {
// toolName := renderToolName(result.ToolName)
//
// // Format the tool input as a string
// inputStr := string(result.ToolInput)
//
// // Format the result
// var resultStr string
// if result.Error != "" {
// resultStr = fmt.Sprintf("Error: %s", result.Error)
// } else {
// var toolResponse tools.ToolResponse
// if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
// resultStr = "Error parsing tool response"
// } else {
// resultStr = truncateHeight(toolResponse.Content, 3)
// }
// }
//
// // Format the tool call
// toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
// toolCalls = append(toolCalls, toolCall)
// }
//
// return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
// default:
// resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
// return styles.ForceReplaceBackgroundWithLipgloss(
// toMarkdown(resultContent, width),
// t.Background(),
// )
// }
}
// func renderToolMessage(
// toolCall message.ToolCall,
// allMessages []message.Message,
// messagesService message.Service,
// focusedUIMessageId string,
// nested bool,
// width int,
// position int,
// ) string {
// if nested {
// width = width - 3
// }
//
// t := theme.CurrentTheme()
// baseStyle := styles.BaseStyle()
//
// style := baseStyle.
// Width(width - 1).
// BorderLeft(true).
// BorderStyle(lipgloss.ThickBorder()).
// PaddingLeft(1).
// BorderForeground(t.TextMuted())
//
// response := findToolResponse(toolCall.ID, allMessages)
// toolNameText := baseStyle.Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s: ", renderToolName(toolCall.Name)))
//
// if !toolCall.Finished {
// // Get a brief description of what the tool is doing
// toolAction := renderToolAction(toolCall.Name)
//
// progressText := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(fmt.Sprintf("%s", toolAction))
//
// content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
// return content
// }
//
// params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
// responseContent := ""
// if response != nil {
// responseContent = renderToolResponse(toolCall, *response, width-2)
// responseContent = strings.TrimSuffix(responseContent, "\n")
// } else {
// responseContent = baseStyle.
// Italic(true).
// Width(width - 2).
// Foreground(t.TextMuted()).
// Render("Waiting for response...")
// }
//
// parts := []string{}
// if !nested {
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
//
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
// } else {
// prefix := baseStyle.
// Foreground(t.TextMuted()).
// Render(" └ ")
// formattedParams := baseStyle.
// Width(width - 2 - lipgloss.Width(toolNameText)).
// Foreground(t.TextMuted()).
// Render(params)
// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
// }
//
// // if toolCall.Name == agent.AgentToolName {
// // taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
// // toolCalls := []message.ToolCall{}
// // for _, v := range taskMessages {
// // toolCalls = append(toolCalls, v.ToolCalls()...)
// // }
// // for _, call := range toolCalls {
// // rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
// // parts = append(parts, rendered.content)
// // }
// // }
// if responseContent != "" && !nested {
// parts = append(parts, responseContent)
// }
//
// content := style.Render(
// lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// ),
// )
// if nested {
// content = lipgloss.JoinVertical(
// lipgloss.Left,
// parts...,
// )
// }
// return content
// }

View File

@@ -0,0 +1,344 @@
package chat
import (
"fmt"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/components/dialog"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
"github.com/sst/opencode/pkg/client"
)
type messagesCmp struct {
app *app.App
width, height int
viewport viewport.Model
spinner spinner.Model
rendering bool
attachments viewport.Model
showToolMessages bool
}
type renderFinishedMsg struct{}
type ToggleToolMessagesMsg struct{}
type MessageKeys struct {
PageDown key.Binding
PageUp key.Binding
HalfPageUp key.Binding
HalfPageDown key.Binding
}
var messageKeys = MessageKeys{
PageDown: key.NewBinding(
key.WithKeys("pgdown"),
key.WithHelp("f/pgdn", "page down"),
),
PageUp: key.NewBinding(
key.WithKeys("pgup"),
key.WithHelp("b/pgup", "page up"),
),
HalfPageUp: key.NewBinding(
key.WithKeys("ctrl+u"),
key.WithHelp("ctrl+u", "½ page up"),
),
HalfPageDown: key.NewBinding(
key.WithKeys("ctrl+d", "ctrl+d"),
key.WithHelp("ctrl+d", "½ page down"),
),
}
func (m *messagesCmp) Init() tea.Cmd {
return tea.Batch(m.viewport.Init(), m.spinner.Tick)
}
func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case dialog.ThemeChangedMsg:
m.renderView()
return m, nil
case ToggleToolMessagesMsg:
m.showToolMessages = !m.showToolMessages
m.renderView()
return m, nil
case state.SessionSelectedMsg:
cmd := m.Reload()
return m, cmd
case state.SessionClearedMsg:
cmd := m.Reload()
return m, cmd
case tea.KeyMsg:
if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
u, cmd := m.viewport.Update(msg)
m.viewport = u
cmds = append(cmds, cmd)
}
case renderFinishedMsg:
m.rendering = false
m.viewport.GotoBottom()
case state.StateUpdatedMsg:
m.renderView()
m.viewport.GotoBottom()
}
spinner, cmd := m.spinner.Update(msg)
m.spinner = spinner
cmds = append(cmds, cmd)
return m, tea.Batch(cmds...)
}
func (m *messagesCmp) renderView() {
if m.width == 0 {
return
}
messages := make([]string, 0)
for _, msg := range m.app.Messages {
switch msg.Role {
case client.User:
content := renderUserMessage(m.app.Info.User, msg, m.width)
messages = append(messages, content+"\n")
case client.Assistant:
content := renderAssistantMessage(msg, m.width, m.showToolMessages)
messages = append(messages, content+"\n")
}
}
m.viewport.SetContent(
styles.BaseStyle().
Render(
lipgloss.JoinVertical(
lipgloss.Top,
messages...,
),
),
)
}
func (m *messagesCmp) View() string {
baseStyle := styles.BaseStyle()
if m.rendering {
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
"Loading...",
m.working(),
m.help(),
),
)
}
if len(m.app.Messages) == 0 {
content := baseStyle.
Width(m.width).
Height(m.height - 1).
Render(
m.initialScreen(),
)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
content,
"",
m.help(),
),
)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
m.viewport.View(),
m.working(),
m.help(),
),
)
}
// func hasToolsWithoutResponse(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// toolResults := make([]message.ToolResult, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// toolResults = append(toolResults, m.ToolResults()...)
// }
//
// for _, v := range toolCalls {
// found := false
// for _, r := range toolResults {
// if v.ID == r.ToolCallID {
// found = true
// break
// }
// }
// if !found && v.Finished {
// return true
// }
// }
// return false
// }
// func hasUnfinishedToolCalls(messages []message.Message) bool {
// toolCalls := make([]message.ToolCall, 0)
// for _, m := range messages {
// toolCalls = append(toolCalls, m.ToolCalls()...)
// }
// for _, v := range toolCalls {
// if !v.Finished {
// return true
// }
// }
// return false
// }
func (m *messagesCmp) working() string {
text := ""
if len(m.app.Messages) > 0 {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
task := ""
lastMessage := m.app.Messages[len(m.app.Messages)-1]
if lastMessage.Metadata.Time.Completed == nil {
task = "Working..."
}
// lastMessage := m.app.Messages[len(m.app.Messages)-1]
// if hasToolsWithoutResponse(m.app.Messages) {
// task = "Waiting for tool response..."
// } else if hasUnfinishedToolCalls(m.app.Messages) {
// task = "Building tool call..."
// } else if !lastMessage.IsFinished() {
// task = "Generating..."
// }
if task != "" {
text += baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
}
}
return text
}
func (m *messagesCmp) help() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
text := ""
// if m.app.PrimaryAgentOLD.IsBusy() {
// text += lipgloss.JoinHorizontal(
// lipgloss.Left,
// baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
// baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
// baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
// )
// } else {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
)
// }
return baseStyle.
Width(m.width).
Render(text)
}
func (m *messagesCmp) initialScreen() string {
baseStyle := styles.BaseStyle()
return baseStyle.Width(m.width).Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.app, m.width),
"",
lspsConfigured(m.width),
),
)
}
func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
if m.width == width && m.height == height {
return nil
}
m.width = width
m.height = height
m.viewport.Width = width
m.viewport.Height = height - 2
m.attachments.Width = width + 40
m.attachments.Height = 3
m.renderView()
return nil
}
func (m *messagesCmp) GetSize() (int, int) {
return m.width, m.height
}
func (m *messagesCmp) Reload() tea.Cmd {
m.rendering = true
return func() tea.Msg {
m.renderView()
return renderFinishedMsg{}
}
}
func (m *messagesCmp) BindingKeys() []key.Binding {
return []key.Binding{
m.viewport.KeyMap.PageDown,
m.viewport.KeyMap.PageUp,
m.viewport.KeyMap.HalfPageUp,
m.viewport.KeyMap.HalfPageDown,
}
}
func NewMessagesCmp(app *app.App) tea.Model {
customSpinner := spinner.Spinner{
Frames: []string{" ", "┃", "┃"},
FPS: time.Second / 3,
}
s := spinner.New(spinner.WithSpinner(customSpinner))
vp := viewport.New(0, 0)
attachments := viewport.New(0, 0)
vp.KeyMap.PageUp = messageKeys.PageUp
vp.KeyMap.PageDown = messageKeys.PageDown
vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
return &messagesCmp{
app: app,
viewport: vp,
spinner: s,
attachments: attachments,
showToolMessages: true,
}
}

View File

@@ -0,0 +1,212 @@
package chat
import (
"fmt"
"sort"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/state"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type sidebarCmp struct {
app *app.App
width, height int
modFiles map[string]struct {
additions int
removals int
}
}
func (m *sidebarCmp) Init() tea.Cmd {
// TODO: History service not implemented in API yet
// Initialize the modified files map
m.modFiles = make(map[string]struct {
additions int
removals int
})
return nil
}
func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case state.SessionSelectedMsg:
// TODO: History service not implemented in API yet
// ctx := context.Background()
// m.loadModifiedFiles(ctx)
// case pubsub.Event[history.File]:
// TODO: History service not implemented in API yet
// if msg.Payload.SessionID == m.app.CurrentSession.ID {
// // Process the individual file change instead of reloading all files
// ctx := context.Background()
// m.processFileChanges(ctx, msg.Payload)
// }
}
return m, nil
}
func (m *sidebarCmp) View() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
shareUrl := ""
if m.app.Session.Share != nil {
shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
}
// qrcode := ""
// if m.app.Session.ShareID != nil {
// url := "https://dev.opencode.ai/share?id="
// qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
// }
return baseStyle.
Width(m.width).
PaddingLeft(4).
PaddingRight(1).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
header(m.app, m.width),
" ",
m.sessionSection(),
shareUrl,
),
)
}
func (m *sidebarCmp) sessionSection() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
sessionKey := baseStyle.
Foreground(t.Primary()).
Bold(true).
Render("Session")
sessionValue := baseStyle.
Foreground(t.Text()).
Render(fmt.Sprintf(": %s", m.app.Session.Title))
return sessionKey + sessionValue
}
func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
stats := ""
if additions > 0 && removals > 0 {
additionsStr := baseStyle.
Foreground(t.Success()).
PaddingLeft(1).
Render(fmt.Sprintf("+%d", additions))
removalsStr := baseStyle.
Foreground(t.Error()).
PaddingLeft(1).
Render(fmt.Sprintf("-%d", removals))
content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
} else if additions > 0 {
additionsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Success()).
Render(fmt.Sprintf("+%d", additions)))
stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
} else if removals > 0 {
removalsStr := fmt.Sprintf(" %s", baseStyle.
PaddingLeft(1).
Foreground(t.Error()).
Render(fmt.Sprintf("-%d", removals)))
stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
}
filePathStr := baseStyle.Render(filePath)
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinHorizontal(
lipgloss.Left,
filePathStr,
stats,
),
)
}
func (m *sidebarCmp) modifiedFiles() string {
t := theme.CurrentTheme()
baseStyle := styles.BaseStyle()
modifiedFiles := baseStyle.
Width(m.width).
Foreground(t.Primary()).
Bold(true).
Render("Modified Files:")
// If no modified files, show a placeholder message
if m.modFiles == nil || len(m.modFiles) == 0 {
message := "No modified files"
remainingWidth := m.width - lipgloss.Width(message)
if remainingWidth > 0 {
message += strings.Repeat(" ", remainingWidth)
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
baseStyle.Foreground(t.TextMuted()).Render(message),
),
)
}
// Sort file paths alphabetically for consistent ordering
var paths []string
for path := range m.modFiles {
paths = append(paths, path)
}
sort.Strings(paths)
// Create views for each file in sorted order
var fileViews []string
for _, path := range paths {
stats := m.modFiles[path]
fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
}
return baseStyle.
Width(m.width).
Render(
lipgloss.JoinVertical(
lipgloss.Top,
modifiedFiles,
lipgloss.JoinVertical(
lipgloss.Left,
fileViews...,
),
),
)
}
func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
m.width = width
m.height = height
return nil
}
func (m *sidebarCmp) GetSize() (int, int) {
return m.width, m.height
}
func NewSidebarCmp(app *app.App) tea.Model {
return &sidebarCmp{
app: app,
}
}