summaryrefslogtreecommitdiffhomepage
path: root/pkg/tui/components/chat
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/tui/components/chat')
-rw-r--r--pkg/tui/components/chat/chat.go133
-rw-r--r--pkg/tui/components/chat/editor.go411
-rw-r--r--pkg/tui/components/chat/message.go704
-rw-r--r--pkg/tui/components/chat/messages.go485
-rw-r--r--pkg/tui/components/chat/sidebar.go355
5 files changed, 2088 insertions, 0 deletions
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), &params)
+ prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
+ return renderParams(paramWidth, prompt)
+ case tools.BashToolName:
+ var params tools.BashParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ command := strings.ReplaceAll(params.Command, "\n", " ")
+ return renderParams(paramWidth, command)
+ case tools.EditToolName:
+ var params tools.EditParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ return renderParams(paramWidth, filePath)
+ case tools.FetchToolName:
+ var params tools.FetchParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ url := params.URL
+ toolParams := []string{
+ url,
+ }
+ if params.Format != "" {
+ toolParams = append(toolParams, "format", params.Format)
+ }
+ if params.Timeout != 0 {
+ toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.GlobToolName:
+ var params tools.GlobParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ pattern := params.Pattern
+ toolParams := []string{
+ pattern,
+ }
+ if params.Path != "" {
+ toolParams = append(toolParams, "path", params.Path)
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.GrepToolName:
+ var params tools.GrepParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ pattern := params.Pattern
+ toolParams := []string{
+ pattern,
+ }
+ if params.Path != "" {
+ toolParams = append(toolParams, "path", params.Path)
+ }
+ if params.Include != "" {
+ toolParams = append(toolParams, "include", params.Include)
+ }
+ if params.LiteralText {
+ toolParams = append(toolParams, "literal", "true")
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.LSToolName:
+ var params tools.LSParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ path := params.Path
+ if path == "" {
+ path = "."
+ }
+ return renderParams(paramWidth, path)
+ case tools.ViewToolName:
+ var params tools.ViewParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ toolParams := []string{
+ filePath,
+ }
+ if params.Limit != 0 {
+ toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+ }
+ if params.Offset != 0 {
+ toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+ }
+ return renderParams(paramWidth, toolParams...)
+ case tools.WriteToolName:
+ var params tools.WriteParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ filePath := removeWorkingDirPrefix(params.FilePath)
+ return renderParams(paramWidth, filePath)
+ case tools.BatchToolName:
+ var params tools.BatchParams
+ json.Unmarshal([]byte(toolCall.Input), &params)
+ return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
+ default:
+ input := strings.ReplaceAll(toolCall.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), &params)
+ mdFormat := "markdown"
+ switch params.Format {
+ case "text":
+ mdFormat = "text"
+ case "html":
+ mdFormat = "html"
+ }
+ resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, 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), &params)
+ metadata := tools.WriteResponseMetadata{}
+ json.Unmarshal([]byte(response.Metadata), &metadata)
+ ext := filepath.Ext(params.FilePath)
+ if ext == "" {
+ ext = ""
+ } else {
+ ext = strings.ToLower(ext[1:])
+ }
+ resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
+ return styles.ForceReplaceBackgroundWithLipgloss(
+ toMarkdown(resultContent, 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, "/")
+}