diff options
| author | adamdottv <[email protected]> | 2025-06-04 09:20:42 -0500 |
|---|---|---|
| committer | adamdottv <[email protected]> | 2025-06-04 09:20:48 -0500 |
| commit | 01050a430f03d479387fdd77816211f5312164af (patch) | |
| tree | 69c1319d8722ce6d0a774234b9528e270dd23400 /packages/tui/internal/components/chat | |
| parent | 0b565b18c48cb2bc6423662b08b8fed14a0cc738 (diff) | |
| download | opencode-01050a430f03d479387fdd77816211f5312164af.tar.gz opencode-01050a430f03d479387fdd77816211f5312164af.zip | |
wip: refactoring tui
Diffstat (limited to 'packages/tui/internal/components/chat')
| -rw-r--r-- | packages/tui/internal/components/chat/chat.go | 131 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 406 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 643 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 344 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/sidebar.go | 212 |
5 files changed, 1736 insertions, 0 deletions
diff --git a/packages/tui/internal/components/chat/chat.go b/packages/tui/internal/components/chat/chat.go new file mode 100644 index 000000000..ad06728d3 --- /dev/null +++ b/packages/tui/internal/components/chat/chat.go @@ -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) +} diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go new file mode 100644 index 000000000..bf5c5875e --- /dev/null +++ b/packages/tui/internal/components/chat/editor.go @@ -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: "", + } +} diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go new file mode 100644 index 000000000..a98586f52 --- /dev/null +++ b/packages/tui/internal/components/chat/message.go @@ -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), ¶ms) + // // prompt := strings.ReplaceAll(params.Prompt, "\n", " ") + // // return renderParams(paramWidth, prompt) + // case "bash": + // var params tools.BashParams + // json.Unmarshal([]byte(toolCall.Input), ¶ms) + // command := strings.ReplaceAll(params.Command, "\n", " ") + // return renderParams(paramWidth, command) + // case "edit": + // var params tools.EditParams + // json.Unmarshal([]byte(toolCall.Input), ¶ms) + // filePath := removeWorkingDirPrefix(params.FilePath) + // return renderParams(paramWidth, filePath) + // case "fetch": + // var params tools.FetchParams + // json.Unmarshal([]byte(toolCall.Input), ¶ms) + // 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), ¶ms) + // 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), ¶ms) + // 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), ¶ms) + // path := params.Path + // if path == "" { + // path = "." + // } + // return renderParams(paramWidth, path) + // case tools.ViewToolName: + // var params tools.ViewParams + // json.Unmarshal([]byte(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)) + // } + // return renderParams(paramWidth, toolParams...) + // case tools.WriteToolName: + // var params tools.WriteParams + // json.Unmarshal([]byte(toolCall.Input), ¶ms) + // filePath := removeWorkingDirPrefix(params.FilePath) + // return renderParams(paramWidth, filePath) + // case tools.BatchToolName: + // var params tools.BatchParams + // json.Unmarshal([]byte(toolCall.Input), ¶ms) + // 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), ¶ms) + // 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), ¶ms) + // 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 +// } diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go new file mode 100644 index 000000000..e18b6d153 --- /dev/null +++ b/packages/tui/internal/components/chat/messages.go @@ -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, + } +} diff --git a/packages/tui/internal/components/chat/sidebar.go b/packages/tui/internal/components/chat/sidebar.go new file mode 100644 index 000000000..b2fe872ea --- /dev/null +++ b/packages/tui/internal/components/chat/sidebar.go @@ -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, + } +} |
