From 99af6146d5def31c59993636d60eb75a483a283b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 18 May 2025 22:30:41 -0400 Subject: openapi --- pkg/tui/components/chat/chat.go | 133 +++++++ pkg/tui/components/chat/editor.go | 411 +++++++++++++++++++++ pkg/tui/components/chat/message.go | 704 ++++++++++++++++++++++++++++++++++++ pkg/tui/components/chat/messages.go | 485 +++++++++++++++++++++++++ pkg/tui/components/chat/sidebar.go | 355 ++++++++++++++++++ 5 files changed, 2088 insertions(+) create mode 100644 pkg/tui/components/chat/chat.go create mode 100644 pkg/tui/components/chat/editor.go create mode 100644 pkg/tui/components/chat/message.go create mode 100644 pkg/tui/components/chat/messages.go create mode 100644 pkg/tui/components/chat/sidebar.go (limited to 'pkg/tui/components/chat') diff --git a/pkg/tui/components/chat/chat.go b/pkg/tui/components/chat/chat.go new file mode 100644 index 000000000..4fa6b27b2 --- /dev/null +++ b/pkg/tui/components/chat/chat.go @@ -0,0 +1,133 @@ +package chat + +import ( + "fmt" + "sort" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" + "github.com/sst/opencode/internal/version" +) + +type SendMsg struct { + Text string + Attachments []message.Attachment +} + +func header(width int) string { + return lipgloss.JoinVertical( + lipgloss.Top, + logo(width), + repo(width), + "", + cwd(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(version.Version) + + 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(width int) string { + cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) + t := theme.CurrentTheme() + + return styles.BaseStyle(). + Foreground(t.TextMuted()). + Width(width). + Render(cwd) +} diff --git a/pkg/tui/components/chat/editor.go b/pkg/tui/components/chat/editor.go new file mode 100644 index 000000000..dbaa05181 --- /dev/null +++ b/pkg/tui/components/chat/editor.go @@ -0,0 +1,411 @@ +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/message" + "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/components/dialog" + "github.com/sst/opencode/internal/tui/image" + "github.com/sst/opencode/internal/tui/layout" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" + "github.com/sst/opencode/internal/tui/util" +) + +type editorCmp struct { + width int + height int + app *app.App + textarea textarea.Model + attachments []message.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 { + if m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) { + status.Warn("Agent is working, please wait...") + return nil + } + + 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.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.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 := message.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 + if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) { + // 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) { + // 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/pkg/tui/components/chat/message.go b/pkg/tui/components/chat/message.go new file mode 100644 index 000000000..f887337ae --- /dev/null +++ b/pkg/tui/components/chat/message.go @@ -0,0 +1,704 @@ +package chat + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/diff" + "github.com/sst/opencode/internal/llm/agent" + "github.com/sst/opencode/internal/llm/models" + "github.com/sst/opencode/internal/llm/tools" + "github.com/sst/opencode/internal/message" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type uiMessageType int + +const ( + userMessageType uiMessageType = iota + assistantMessageType + toolMessageType + + maxResultHeight = 10 +) + +type uiMessage struct { + ID string + messageType uiMessageType + position int + height int + content string +} + +func toMarkdown(content string, focused bool, width int) string { + r := styles.GetMarkdownRenderer(width) + rendered, _ := r.Render(content) + return rendered +} + +func renderMessage(msg string, isUser bool, isFocused bool, width int, info ...string) string { + t := theme.CurrentTheme() + + style := styles.BaseStyle(). + Width(width - 1). + BorderLeft(true). + Foreground(t.TextMuted()). + BorderForeground(t.Primary()). + BorderStyle(lipgloss.ThickBorder()) + + if isUser { + style = style.BorderForeground(t.Secondary()) + } + + // Apply markdown formatting and handle background color + parts := []string{ + styles.ForceReplaceBackgroundWithLipgloss(toMarkdown(msg, isFocused, width), t.Background()), + } + + // Remove newline at the end + parts[0] = strings.TrimSuffix(parts[0], "\n") + if len(info) > 0 { + parts = append(parts, info...) + } + + rendered := style.Render( + lipgloss.JoinVertical( + lipgloss.Left, + parts..., + ), + ) + + return rendered +} + +func renderUserMessage(msg message.Message, isFocused bool, width int, position int) uiMessage { + var styledAttachments []string + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + 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)) + } + + // Add timestamp info + info := []string{} + timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM") + username, _ := config.GetUsername() + info = append(info, baseStyle. + Width(width-1). + Foreground(t.TextMuted()). + Render(fmt.Sprintf(" %s (%s)", username, 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 { + content = renderMessage(msg.Content().String(), true, isFocused, width, info...) + } + userMsg := uiMessage{ + ID: msg.ID, + messageType: userMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return userMsg +} + +// Returns multiple uiMessages because of the tool calls +func renderAssistantMessage( + msg message.Message, + msgIndex int, + allMessages []message.Message, // we need this to get tool results and the user message + messagesService message.Service, // We need this to get the task tool messages + focusedUIMessageId string, + width int, + position int, + showToolMessages bool, +) []uiMessage { + messages := []uiMessage{} + content := strings.TrimSpace(msg.Content().String()) + thinking := msg.IsThinking() + thinkingContent := msg.ReasoningContent().Thinking + finished := msg.IsFinished() + finishData := msg.FinishPart() + info := []string{} + + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + // Always add timestamp info + timestamp := msg.CreatedAt.Local().Format("02 Jan 2006 03:04 PM") + modelName := "Assistant" + if msg.Model != "" { + modelName = models.SupportedModels[msg.Model].Name + } + + info = append(info, baseStyle. + Width(width-1). + Foreground(t.TextMuted()). + Render(fmt.Sprintf(" %s (%s)", modelName, timestamp)), + ) + + if finished { + // Add finish info if available + switch finishData.Reason { + case message.FinishReasonCanceled: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Warning()). + Render("(canceled)"), + ) + case message.FinishReasonError: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Error()). + Render("(error)"), + ) + case message.FinishReasonPermissionDenied: + info = append(info, baseStyle. + Width(width-1). + Foreground(t.Info()). + Render("(permission denied)"), + ) + } + } + + if content != "" || (finished && finishData.Reason == message.FinishReasonEndTurn) { + if content == "" { + content = "*Finished without output*" + } + + content = renderMessage(content, false, true, width, info...) + messages = append(messages, uiMessage{ + ID: msg.ID, + messageType: assistantMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + }) + position += messages[0].height + position++ // for the space + } else if thinking && thinkingContent != "" { + // Render the thinking content with timestamp + content = renderMessage(thinkingContent, false, msg.ID == focusedUIMessageId, width, info...) + messages = append(messages, uiMessage{ + ID: msg.ID, + messageType: assistantMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + }) + position += lipgloss.Height(content) + position++ // for the space + } + + // Only render tool messages if they should be shown + if showToolMessages { + for i, toolCall := range msg.ToolCalls() { + toolCallContent := renderToolMessage( + toolCall, + allMessages, + messagesService, + focusedUIMessageId, + false, + width, + i+1, + ) + messages = append(messages, toolCallContent) + position += toolCallContent.height + position++ // for the space + } + } + return messages +} + +func findToolResponse(toolCallID string, futureMessages []message.Message) *message.ToolResult { + for _, msg := range futureMessages { + for _, result := range msg.ToolResults() { + if result.ToolCallID == toolCallID { + return &result + } + } + } + return nil +} + +func toolName(name string) string { + switch name { + case agent.AgentToolName: + return "Task" + case tools.BashToolName: + return "Bash" + case tools.EditToolName: + return "Edit" + case tools.FetchToolName: + return "Fetch" + case tools.GlobToolName: + return "Glob" + case tools.GrepToolName: + return "Grep" + case tools.LSToolName: + return "List" + case tools.ViewToolName: + return "View" + case tools.WriteToolName: + return "Write" + case tools.PatchToolName: + return "Patch" + case tools.BatchToolName: + return "Batch" + } + return name +} + +func getToolAction(name string) string { + switch name { + case agent.AgentToolName: + return "Preparing prompt..." + case tools.BashToolName: + return "Building command..." + case tools.EditToolName: + return "Preparing edit..." + case tools.FetchToolName: + return "Writing fetch..." + case tools.GlobToolName: + return "Finding files..." + case tools.GrepToolName: + return "Searching content..." + case tools.LSToolName: + return "Listing directory..." + case tools.ViewToolName: + return "Reading file..." + case tools.WriteToolName: + return "Preparing write..." + case tools.PatchToolName: + return "Preparing patch..." + case tools.BatchToolName: + 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 removeWorkingDirPrefix(path string) string { + wd := config.WorkingDirectory() + if strings.HasPrefix(path, wd) { + path = strings.TrimPrefix(path, wd) + } + if strings.HasPrefix(path, "/") { + path = strings.TrimPrefix(path, "/") + } + if strings.HasPrefix(path, "./") { + path = strings.TrimPrefix(path, "./") + } + if strings.HasPrefix(path, "../") { + path = strings.TrimPrefix(path, "../") + } + return path +} + +func renderToolParams(paramWidth int, toolCall message.ToolCall) string { + params := "" + switch toolCall.Name { + case agent.AgentToolName: + var params agent.AgentParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + prompt := strings.ReplaceAll(params.Prompt, "\n", " ") + return renderParams(paramWidth, prompt) + case tools.BashToolName: + var params tools.BashParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + command := strings.ReplaceAll(params.Command, "\n", " ") + return renderParams(paramWidth, command) + case tools.EditToolName: + var params tools.EditParams + json.Unmarshal([]byte(toolCall.Input), ¶ms) + filePath := removeWorkingDirPrefix(params.FilePath) + return renderParams(paramWidth, filePath) + case tools.FetchToolName: + 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.Input, "\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 message.ToolCall, response message.ToolResult, width int) string { + 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, true, 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, true, 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, true, 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, true, 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 := toolName(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, true, width), + t.Background(), + ) + } +} + +func renderToolMessage( + toolCall message.ToolCall, + allMessages []message.Message, + messagesService message.Service, + focusedUIMessageId string, + nested bool, + width int, + position int, +) uiMessage { + 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: ", toolName(toolCall.Name))) + + if !toolCall.Finished { + // Get a brief description of what the tool is doing + toolAction := getToolAction(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)) + toolMsg := uiMessage{ + messageType: toolMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return toolMsg + } + + 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..., + ) + } + toolMsg := uiMessage{ + messageType: toolMessageType, + position: position, + height: lipgloss.Height(content), + content: content, + } + return toolMsg +} diff --git a/pkg/tui/components/chat/messages.go b/pkg/tui/components/chat/messages.go new file mode 100644 index 000000000..d6f252aad --- /dev/null +++ b/pkg/tui/components/chat/messages.go @@ -0,0 +1,485 @@ +package chat + +import ( + "context" + "fmt" + "math" + "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/message" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/session" + "github.com/sst/opencode/internal/status" + "github.com/sst/opencode/internal/tui/components/dialog" + "github.com/sst/opencode/internal/tui/state" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type cacheItem struct { + width int + content []uiMessage +} + +type messagesCmp struct { + app *app.App + width, height int + viewport viewport.Model + messages []message.Message + uiMessages []uiMessage + currentMsgID string + cachedContent map[string]cacheItem + 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.rerender() + return m, nil + case ToggleToolMessagesMsg: + m.showToolMessages = !m.showToolMessages + // Clear the cache to force re-rendering of all messages + m.cachedContent = make(map[string]cacheItem) + m.renderView() + return m, nil + case state.SessionSelectedMsg: + cmd := m.Reload(msg) + return m, cmd + case state.SessionClearedMsg: + m.messages = make([]message.Message, 0) + m.currentMsgID = "" + m.rendering = false + return m, nil + 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 pubsub.Event[message.Message]: + needsRerender := false + if msg.Type == message.EventMessageCreated { + if msg.Payload.SessionID == m.app.CurrentSession.ID { + messageExists := false + for _, v := range m.messages { + if v.ID == msg.Payload.ID { + messageExists = true + break + } + } + + if !messageExists { + if len(m.messages) > 0 { + lastMsgID := m.messages[len(m.messages)-1].ID + delete(m.cachedContent, lastMsgID) + } + + m.messages = append(m.messages, msg.Payload) + delete(m.cachedContent, m.currentMsgID) + m.currentMsgID = msg.Payload.ID + needsRerender = true + } + } + // There are tool calls from the child task + for _, v := range m.messages { + for _, c := range v.ToolCalls() { + if c.ID == msg.Payload.SessionID { + delete(m.cachedContent, v.ID) + needsRerender = true + } + } + } + } else if msg.Type == message.EventMessageUpdated && msg.Payload.SessionID == m.app.CurrentSession.ID { + for i, v := range m.messages { + if v.ID == msg.Payload.ID { + m.messages[i] = msg.Payload + delete(m.cachedContent, msg.Payload.ID) + needsRerender = true + break + } + } + } + if needsRerender { + m.renderView() + if len(m.messages) > 0 { + if (msg.Type == message.EventMessageCreated) || + (msg.Type == message.EventMessageUpdated && msg.Payload.ID == m.messages[len(m.messages)-1].ID) { + m.viewport.GotoBottom() + } + } + } + } + + spinner, cmd := m.spinner.Update(msg) + m.spinner = spinner + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func (m *messagesCmp) IsAgentWorking() bool { + return m.app.PrimaryAgent.IsSessionBusy(m.app.CurrentSession.ID) +} + +func formatTimeDifference(unixTime1, unixTime2 int64) string { + diffSeconds := float64(math.Abs(float64(unixTime2 - unixTime1))) + + if diffSeconds < 60 { + return fmt.Sprintf("%.1fs", diffSeconds) + } + + minutes := int(diffSeconds / 60) + seconds := int(diffSeconds) % 60 + return fmt.Sprintf("%dm%ds", minutes, seconds) +} + +func (m *messagesCmp) renderView() { + m.uiMessages = make([]uiMessage, 0) + pos := 0 + baseStyle := styles.BaseStyle() + + if m.width == 0 { + return + } + for inx, msg := range m.messages { + switch msg.Role { + case message.User: + if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { + m.uiMessages = append(m.uiMessages, cache.content...) + continue + } + userMsg := renderUserMessage( + msg, + msg.ID == m.currentMsgID, + m.width, + pos, + ) + m.uiMessages = append(m.uiMessages, userMsg) + m.cachedContent[msg.ID] = cacheItem{ + width: m.width, + content: []uiMessage{userMsg}, + } + pos += userMsg.height + 1 // + 1 for spacing + case message.Assistant: + if cache, ok := m.cachedContent[msg.ID]; ok && cache.width == m.width { + m.uiMessages = append(m.uiMessages, cache.content...) + continue + } + assistantMessages := renderAssistantMessage( + msg, + inx, + m.messages, + m.app.Messages, + m.currentMsgID, + m.width, + pos, + m.showToolMessages, + ) + for _, msg := range assistantMessages { + m.uiMessages = append(m.uiMessages, msg) + pos += msg.height + 1 // + 1 for spacing + } + m.cachedContent[msg.ID] = cacheItem{ + width: m.width, + content: assistantMessages, + } + } + } + + messages := make([]string, 0) + for _, v := range m.uiMessages { + messages = append(messages, lipgloss.JoinVertical(lipgloss.Left, v.content), + baseStyle. + Width(m.width). + Render( + "", + ), + ) + } + + m.viewport.SetContent( + baseStyle. + Width(m.width). + 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.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 m.IsAgentWorking() && len(m.messages) > 0 { + t := theme.CurrentTheme() + baseStyle := styles.BaseStyle() + + task := "Thinking..." + lastMessage := m.messages[len(m.messages)-1] + if hasToolsWithoutResponse(m.messages) { + task = "Waiting for tool response..." + } else if hasUnfinishedToolCalls(m.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.PrimaryAgent.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.width), + "", + lspsConfigured(m.width), + ), + ) +} + +func (m *messagesCmp) rerender() { + for _, msg := range m.messages { + delete(m.cachedContent, msg.ID) + } + m.renderView() +} + +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.rerender() + return nil +} + +func (m *messagesCmp) GetSize() (int, int) { + return m.width, m.height +} + +func (m *messagesCmp) Reload(session *session.Session) tea.Cmd { + messages, err := m.app.Messages.List(context.Background(), session.ID) + if err != nil { + status.Error(err.Error()) + return nil + } + m.messages = messages + if len(m.messages) > 0 { + m.currentMsgID = m.messages[len(m.messages)-1].ID + } + delete(m.cachedContent, m.currentMsgID) + 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) + attachmets := 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, + cachedContent: make(map[string]cacheItem), + viewport: vp, + spinner: s, + attachments: attachmets, + showToolMessages: true, + } +} diff --git a/pkg/tui/components/chat/sidebar.go b/pkg/tui/components/chat/sidebar.go new file mode 100644 index 000000000..973b03ef1 --- /dev/null +++ b/pkg/tui/components/chat/sidebar.go @@ -0,0 +1,355 @@ +package chat + +import ( + "context" + "fmt" + "sort" + "strings" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/config" + "github.com/sst/opencode/internal/diff" + "github.com/sst/opencode/internal/history" + "github.com/sst/opencode/internal/pubsub" + "github.com/sst/opencode/internal/tui/state" + "github.com/sst/opencode/internal/tui/styles" + "github.com/sst/opencode/internal/tui/theme" +) + +type sidebarCmp struct { + app *app.App + width, height int + modFiles map[string]struct { + additions int + removals int + } +} + +func (m *sidebarCmp) Init() tea.Cmd { + if m.app.History != nil { + ctx := context.Background() + // Subscribe to file events + filesCh := m.app.History.Subscribe(ctx) + + // Initialize the modified files map + m.modFiles = make(map[string]struct { + additions int + removals int + }) + + // Load initial files and calculate diffs + m.loadModifiedFiles(ctx) + + // Return a command that will send file events to the Update method + return func() tea.Msg { + return <-filesCh + } + } + return nil +} + +func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case state.SessionSelectedMsg: + ctx := context.Background() + m.loadModifiedFiles(ctx) + case pubsub.Event[history.File]: + 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 { + baseStyle := styles.BaseStyle() + + return baseStyle. + Width(m.width). + PaddingLeft(4). + PaddingRight(1). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + header(m.width), + " ", + m.sessionSection(), + " ", + lspsConfigured(m.width), + " ", + m.modifiedFiles(), + ), + ) +} + +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.CurrentSession.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, + } +} + +func (m *sidebarCmp) loadModifiedFiles(ctx context.Context) { + if m.app.CurrentSession.ID == "" { + return + } + + // Get all latest files for this session + latestFiles, err := m.app.History.ListLatestSessionFiles(ctx, m.app.CurrentSession.ID) + if err != nil { + return + } + + // Get all files for this session (to find initial versions) + allFiles, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) + if err != nil { + return + } + + // Clear the existing map to rebuild it + m.modFiles = make(map[string]struct { + additions int + removals int + }) + + // Process each latest file + for _, file := range latestFiles { + // Skip if this is the initial version (no changes to show) + if file.Version == history.InitialVersion { + continue + } + + // Find the initial version for this specific file + var initialVersion history.File + for _, v := range allFiles { + if v.Path == file.Path && v.Version == history.InitialVersion { + initialVersion = v + break + } + } + + // Skip if we can't find the initial version + if initialVersion.ID == "" { + continue + } + if initialVersion.Content == file.Content { + continue + } + + // Calculate diff between initial and latest version + _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) + + // Only add to modified files if there are changes + if additions > 0 || removals > 0 { + // Remove working directory prefix from file path + displayPath := file.Path + workingDir := config.WorkingDirectory() + displayPath = strings.TrimPrefix(displayPath, workingDir) + displayPath = strings.TrimPrefix(displayPath, "/") + + m.modFiles[displayPath] = struct { + additions int + removals int + }{ + additions: additions, + removals: removals, + } + } + } +} + +func (m *sidebarCmp) processFileChanges(ctx context.Context, file history.File) { + // Skip if this is the initial version (no changes to show) + if file.Version == history.InitialVersion { + return + } + + // Find the initial version for this file + initialVersion, err := m.findInitialVersion(ctx, file.Path) + if err != nil || initialVersion.ID == "" { + return + } + + // Skip if content hasn't changed + if initialVersion.Content == file.Content { + // If this file was previously modified but now matches the initial version, + // remove it from the modified files list + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) + return + } + + // Calculate diff between initial and latest version + _, additions, removals := diff.GenerateDiff(initialVersion.Content, file.Content, file.Path) + + // Only add to modified files if there are changes + if additions > 0 || removals > 0 { + displayPath := getDisplayPath(file.Path) + m.modFiles[displayPath] = struct { + additions int + removals int + }{ + additions: additions, + removals: removals, + } + } else { + // If no changes, remove from modified files + displayPath := getDisplayPath(file.Path) + delete(m.modFiles, displayPath) + } +} + +// Helper function to find the initial version of a file +func (m *sidebarCmp) findInitialVersion(ctx context.Context, path string) (history.File, error) { + // Get all versions of this file for the session + fileVersions, err := m.app.History.ListBySession(ctx, m.app.CurrentSession.ID) + if err != nil { + return history.File{}, err + } + + // Find the initial version + for _, v := range fileVersions { + if v.Path == path && v.Version == history.InitialVersion { + return v, nil + } + } + + return history.File{}, fmt.Errorf("initial version not found") +} + +// Helper function to get the display path for a file +func getDisplayPath(path string) string { + workingDir := config.WorkingDirectory() + displayPath := strings.TrimPrefix(path, workingDir) + return strings.TrimPrefix(displayPath, "/") +} -- cgit v1.2.3