summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/chat
diff options
context:
space:
mode:
authoradamdottv <[email protected]>2025-06-04 09:20:42 -0500
committeradamdottv <[email protected]>2025-06-04 09:20:48 -0500
commit01050a430f03d479387fdd77816211f5312164af (patch)
tree69c1319d8722ce6d0a774234b9528e270dd23400 /packages/tui/internal/components/chat
parent0b565b18c48cb2bc6423662b08b8fed14a0cc738 (diff)
downloadopencode-01050a430f03d479387fdd77816211f5312164af.tar.gz
opencode-01050a430f03d479387fdd77816211f5312164af.zip
wip: refactoring tui
Diffstat (limited to 'packages/tui/internal/components/chat')
-rw-r--r--packages/tui/internal/components/chat/chat.go131
-rw-r--r--packages/tui/internal/components/chat/editor.go406
-rw-r--r--packages/tui/internal/components/chat/message.go643
-rw-r--r--packages/tui/internal/components/chat/messages.go344
-rw-r--r--packages/tui/internal/components/chat/sidebar.go212
5 files changed, 1736 insertions, 0 deletions
diff --git a/packages/tui/internal/components/chat/chat.go b/packages/tui/internal/components/chat/chat.go
new file mode 100644
index 000000000..ad06728d3
--- /dev/null
+++ b/packages/tui/internal/components/chat/chat.go
@@ -0,0 +1,131 @@
+package chat
+
+import (
+ "fmt"
+ "sort"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+)
+
+type SendMsg struct {
+ Text string
+ Attachments []app.Attachment
+}
+
+func header(app *app.App, width int) string {
+ return lipgloss.JoinVertical(
+ lipgloss.Top,
+ logo(width),
+ repo(width),
+ "",
+ cwd(app, width),
+ )
+}
+
+func lspsConfigured(width int) string {
+ // cfg := config.Get()
+ title := "LSP Servers"
+ title = ansi.Truncate(title, width, "…")
+
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ lsps := baseStyle.
+ Width(width).
+ Foreground(t.Primary()).
+ Bold(true).
+ Render(title)
+
+ // Get LSP names and sort them for consistent ordering
+ var lspNames []string
+ // for name := range cfg.LSP {
+ // lspNames = append(lspNames, name)
+ // }
+ sort.Strings(lspNames)
+
+ var lspViews []string
+ // for _, name := range lspNames {
+ // lsp := cfg.LSP[name]
+ // lspName := baseStyle.
+ // Foreground(t.Text()).
+ // Render(fmt.Sprintf("• %s", name))
+
+ // cmd := lsp.Command
+ // cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…")
+
+ // lspPath := baseStyle.
+ // Foreground(t.TextMuted()).
+ // Render(fmt.Sprintf(" (%s)", cmd))
+
+ // lspViews = append(lspViews,
+ // baseStyle.
+ // Width(width).
+ // Render(
+ // lipgloss.JoinHorizontal(
+ // lipgloss.Left,
+ // lspName,
+ // lspPath,
+ // ),
+ // ),
+ // )
+ // }
+
+ return baseStyle.
+ Width(width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lsps,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ lspViews...,
+ ),
+ ),
+ )
+}
+
+func logo(width int) string {
+ logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode")
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ versionText := baseStyle.
+ Foreground(t.TextMuted()).
+ Render("v0.0.1") // TODO: get version from server
+
+ return baseStyle.
+ Bold(true).
+ Width(width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ logo,
+ " ",
+ versionText,
+ ),
+ )
+}
+
+func repo(width int) string {
+ repo := "github.com/sst/opencode"
+ t := theme.CurrentTheme()
+
+ return styles.BaseStyle().
+ Foreground(t.TextMuted()).
+ Width(width).
+ Render(repo)
+}
+
+func cwd(app *app.App, width int) string {
+ cwd := fmt.Sprintf("cwd: %s", app.Info.Path.Cwd)
+ t := theme.CurrentTheme()
+
+ return styles.BaseStyle().
+ Foreground(t.TextMuted()).
+ Width(width).
+ Render(cwd)
+}
diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go
new file mode 100644
index 000000000..bf5c5875e
--- /dev/null
+++ b/packages/tui/internal/components/chat/editor.go
@@ -0,0 +1,406 @@
+package chat
+
+import (
+ "fmt"
+ "log/slog"
+ "os"
+ "os/exec"
+ "slices"
+ "strings"
+ "unicode"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/textarea"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/components/dialog"
+ "github.com/sst/opencode/internal/image"
+ "github.com/sst/opencode/internal/layout"
+ "github.com/sst/opencode/internal/status"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+ "github.com/sst/opencode/internal/util"
+)
+
+type editorCmp struct {
+ width int
+ height int
+ app *app.App
+ textarea textarea.Model
+ attachments []app.Attachment
+ deleteMode bool
+ history []string
+ historyIndex int
+ currentMessage string
+}
+
+type EditorKeyMaps struct {
+ Send key.Binding
+ OpenEditor key.Binding
+ Paste key.Binding
+ HistoryUp key.Binding
+ HistoryDown key.Binding
+}
+
+type bluredEditorKeyMaps struct {
+ Send key.Binding
+ Focus key.Binding
+ OpenEditor key.Binding
+}
+type DeleteAttachmentKeyMaps struct {
+ AttachmentDeleteMode key.Binding
+ Escape key.Binding
+ DeleteAllAttachments key.Binding
+}
+
+var editorMaps = EditorKeyMaps{
+ Send: key.NewBinding(
+ key.WithKeys("enter", "ctrl+s"),
+ key.WithHelp("enter", "send message"),
+ ),
+ OpenEditor: key.NewBinding(
+ key.WithKeys("ctrl+e"),
+ key.WithHelp("ctrl+e", "open editor"),
+ ),
+ Paste: key.NewBinding(
+ key.WithKeys("ctrl+v"),
+ key.WithHelp("ctrl+v", "paste content"),
+ ),
+ HistoryUp: key.NewBinding(
+ key.WithKeys("up"),
+ key.WithHelp("up", "previous message"),
+ ),
+ HistoryDown: key.NewBinding(
+ key.WithKeys("down"),
+ key.WithHelp("down", "next message"),
+ ),
+}
+
+var DeleteKeyMaps = DeleteAttachmentKeyMaps{
+ AttachmentDeleteMode: key.NewBinding(
+ key.WithKeys("ctrl+r"),
+ key.WithHelp("ctrl+r+{i}", "delete attachment at index i"),
+ ),
+ Escape: key.NewBinding(
+ key.WithKeys("esc"),
+ key.WithHelp("esc", "cancel delete mode"),
+ ),
+ DeleteAllAttachments: key.NewBinding(
+ key.WithKeys("r"),
+ key.WithHelp("ctrl+r+r", "delete all attachments"),
+ ),
+}
+
+const (
+ maxAttachments = 5
+)
+
+func (m *editorCmp) openEditor(value string) tea.Cmd {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = "nvim"
+ }
+
+ tmpfile, err := os.CreateTemp("", "msg_*.md")
+ tmpfile.WriteString(value)
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ tmpfile.Close()
+ c := exec.Command(editor, tmpfile.Name()) //nolint:gosec
+ c.Stdin = os.Stdin
+ c.Stdout = os.Stdout
+ c.Stderr = os.Stderr
+ return tea.ExecProcess(c, func(err error) tea.Msg {
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ content, err := os.ReadFile(tmpfile.Name())
+ if err != nil {
+ status.Error(err.Error())
+ return nil
+ }
+ if len(content) == 0 {
+ status.Warn("Message is empty")
+ return nil
+ }
+ os.Remove(tmpfile.Name())
+ attachments := m.attachments
+ m.attachments = nil
+ return SendMsg{
+ Text: string(content),
+ Attachments: attachments,
+ }
+ })
+}
+
+func (m *editorCmp) Init() tea.Cmd {
+ return textarea.Blink
+}
+
+func (m *editorCmp) send() tea.Cmd {
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ attachments := m.attachments
+
+ // Save to history if not empty and not a duplicate of the last entry
+ if value != "" {
+ if len(m.history) == 0 || m.history[len(m.history)-1] != value {
+ m.history = append(m.history, value)
+ }
+ m.historyIndex = len(m.history)
+ m.currentMessage = ""
+ }
+
+ m.attachments = nil
+ if value == "" {
+ return nil
+ }
+ return tea.Batch(
+ util.CmdHandler(SendMsg{
+ Text: value,
+ Attachments: attachments,
+ }),
+ )
+}
+
+func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmd tea.Cmd
+ switch msg := msg.(type) {
+ case dialog.ThemeChangedMsg:
+ m.textarea = CreateTextArea(&m.textarea)
+ case dialog.CompletionSelectedMsg:
+ existingValue := m.textarea.Value()
+ modifiedValue := strings.Replace(existingValue, msg.SearchString, msg.CompletionValue, 1)
+
+ m.textarea.SetValue(modifiedValue)
+ return m, nil
+ case dialog.AttachmentAddedMsg:
+ if len(m.attachments) >= maxAttachments {
+ status.Error(fmt.Sprintf("cannot add more than %d images", maxAttachments))
+ return m, cmd
+ }
+ m.attachments = append(m.attachments, msg.Attachment)
+ case tea.KeyMsg:
+ if key.Matches(msg, DeleteKeyMaps.AttachmentDeleteMode) {
+ m.deleteMode = true
+ return m, nil
+ }
+ if key.Matches(msg, DeleteKeyMaps.DeleteAllAttachments) && m.deleteMode {
+ m.deleteMode = false
+ m.attachments = nil
+ return m, nil
+ }
+ if m.deleteMode && len(msg.Runes) > 0 && unicode.IsDigit(msg.Runes[0]) {
+ num := int(msg.Runes[0] - '0')
+ m.deleteMode = false
+ if num < 10 && len(m.attachments) > num {
+ if num == 0 {
+ m.attachments = m.attachments[num+1:]
+ } else {
+ m.attachments = slices.Delete(m.attachments, num, num+1)
+ }
+ return m, nil
+ }
+ }
+ if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
+ key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
+ return m, nil
+ }
+ if key.Matches(msg, editorMaps.OpenEditor) {
+ // if m.app.PrimaryAgentOLD.IsSessionBusy(m.app.CurrentSessionOLD.ID) {
+ // status.Warn("Agent is working, please wait...")
+ // return m, nil
+ // }
+ value := m.textarea.Value()
+ m.textarea.Reset()
+ return m, m.openEditor(value)
+ }
+ if key.Matches(msg, DeleteKeyMaps.Escape) {
+ m.deleteMode = false
+ return m, nil
+ }
+
+ if key.Matches(msg, editorMaps.Paste) {
+ imageBytes, text, err := image.GetImageFromClipboard()
+ if err != nil {
+ slog.Error(err.Error())
+ return m, cmd
+ }
+ if len(imageBytes) != 0 {
+ attachmentName := fmt.Sprintf("clipboard-image-%d", len(m.attachments))
+ attachment := app.Attachment{FilePath: attachmentName, FileName: attachmentName, Content: imageBytes, MimeType: "image/png"}
+ m.attachments = append(m.attachments, attachment)
+ } else {
+ m.textarea.SetValue(m.textarea.Value() + text)
+ }
+ return m, cmd
+ }
+
+ // Handle history navigation with up/down arrow keys
+ // Only handle history navigation if the filepicker is not open and completion dialog is not open
+ if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
+ // Get the current line number
+ currentLine := m.textarea.Line()
+
+ // Only navigate history if we're at the first line
+ if currentLine == 0 && len(m.history) > 0 {
+ // Save current message if we're just starting to navigate
+ if m.historyIndex == len(m.history) {
+ m.currentMessage = m.textarea.Value()
+ }
+
+ // Go to previous message in history
+ if m.historyIndex > 0 {
+ m.historyIndex--
+ m.textarea.SetValue(m.history[m.historyIndex])
+ }
+ return m, nil
+ }
+ }
+
+ if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
+ // Get the current line number and total lines
+ currentLine := m.textarea.Line()
+ value := m.textarea.Value()
+ lines := strings.Split(value, "\n")
+ totalLines := len(lines)
+
+ // Only navigate history if we're at the last line
+ if currentLine == totalLines-1 {
+ if m.historyIndex < len(m.history)-1 {
+ // Go to next message in history
+ m.historyIndex++
+ m.textarea.SetValue(m.history[m.historyIndex])
+ } else if m.historyIndex == len(m.history)-1 {
+ // Return to the current message being composed
+ m.historyIndex = len(m.history)
+ m.textarea.SetValue(m.currentMessage)
+ }
+ return m, nil
+ }
+ }
+
+ // Handle Enter key
+ if m.textarea.Focused() && key.Matches(msg, editorMaps.Send) {
+ value := m.textarea.Value()
+ if len(value) > 0 && value[len(value)-1] == '\\' {
+ // If the last character is a backslash, remove it and add a newline
+ m.textarea.SetValue(value[:len(value)-1] + "\n")
+ return m, nil
+ } else {
+ // Otherwise, send the message
+ return m, m.send()
+ }
+ }
+
+ }
+ m.textarea, cmd = m.textarea.Update(msg)
+ return m, cmd
+}
+
+func (m *editorCmp) View() string {
+ t := theme.CurrentTheme()
+
+ // Style the prompt with theme colors
+ style := lipgloss.NewStyle().
+ Padding(0, 0, 0, 1).
+ Bold(true).
+ Foreground(t.Primary())
+
+ if len(m.attachments) == 0 {
+ return lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"), m.textarea.View())
+ }
+ m.textarea.SetHeight(m.height - 1)
+ return lipgloss.JoinVertical(lipgloss.Top,
+ m.attachmentsContent(),
+ lipgloss.JoinHorizontal(lipgloss.Top, style.Render(">"),
+ m.textarea.View()),
+ )
+}
+
+func (m *editorCmp) SetSize(width, height int) tea.Cmd {
+ m.width = width
+ m.height = height
+ m.textarea.SetWidth(width - 3) // account for the prompt and padding right
+ m.textarea.SetHeight(height)
+ return nil
+}
+
+func (m *editorCmp) GetSize() (int, int) {
+ return m.textarea.Width(), m.textarea.Height()
+}
+
+func (m *editorCmp) attachmentsContent() string {
+ var styledAttachments []string
+ t := theme.CurrentTheme()
+ attachmentStyles := styles.BaseStyle().
+ MarginLeft(1).
+ Background(t.TextMuted()).
+ Foreground(t.Text())
+ for i, attachment := range m.attachments {
+ var filename string
+ if len(attachment.FileName) > 10 {
+ filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, attachment.FileName[0:7])
+ } else {
+ filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, attachment.FileName)
+ }
+ if m.deleteMode {
+ filename = fmt.Sprintf("%d%s", i, filename)
+ }
+ styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
+ }
+ content := lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...)
+ return content
+}
+
+func (m *editorCmp) BindingKeys() []key.Binding {
+ bindings := []key.Binding{}
+ bindings = append(bindings, layout.KeyMapToSlice(editorMaps)...)
+ bindings = append(bindings, layout.KeyMapToSlice(DeleteKeyMaps)...)
+ return bindings
+}
+
+func CreateTextArea(existing *textarea.Model) textarea.Model {
+ t := theme.CurrentTheme()
+ bgColor := t.Background()
+ textColor := t.Text()
+ textMutedColor := t.TextMuted()
+
+ ta := textarea.New()
+ ta.BlurredStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+ ta.BlurredStyle.CursorLine = styles.BaseStyle().Background(bgColor)
+ ta.BlurredStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
+ ta.BlurredStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+ ta.FocusedStyle.Base = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+ ta.FocusedStyle.CursorLine = styles.BaseStyle().Background(bgColor)
+ ta.FocusedStyle.Placeholder = styles.BaseStyle().Background(bgColor).Foreground(textMutedColor)
+ ta.FocusedStyle.Text = styles.BaseStyle().Background(bgColor).Foreground(textColor)
+
+ ta.Prompt = " "
+ ta.ShowLineNumbers = false
+ ta.CharLimit = -1
+
+ if existing != nil {
+ ta.SetValue(existing.Value())
+ ta.SetWidth(existing.Width())
+ ta.SetHeight(existing.Height())
+ }
+
+ ta.Focus()
+ return ta
+}
+
+func NewEditorCmp(app *app.App) tea.Model {
+ ta := CreateTextArea(nil)
+ return &editorCmp{
+ app: app,
+ textarea: ta,
+ history: []string{},
+ historyIndex: 0,
+ currentMessage: "",
+ }
+}
diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go
new file mode 100644
index 000000000..a98586f52
--- /dev/null
+++ b/packages/tui/internal/components/chat/message.go
@@ -0,0 +1,643 @@
+package chat
+
+import (
+ "fmt"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/charmbracelet/x/ansi"
+ "github.com/sst/opencode/internal/components/diff"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+ "github.com/sst/opencode/pkg/client"
+ "golang.org/x/text/cases"
+ "golang.org/x/text/language"
+)
+
+const (
+ maxResultHeight = 10
+)
+
+func toMarkdown(content string, width int) string {
+ r := styles.GetMarkdownRenderer(width)
+ rendered, _ := r.Render(content)
+ return strings.TrimSuffix(rendered, "\n")
+}
+
+func renderUserMessage(user string, msg client.MessageInfo, width int) string {
+ t := theme.CurrentTheme()
+ style := styles.BaseStyle().
+ BorderLeft(true).
+ Foreground(t.TextMuted()).
+ BorderForeground(t.Secondary()).
+ BorderStyle(lipgloss.ThickBorder())
+
+ // var styledAttachments []string
+ // attachmentStyles := baseStyle.
+ // MarginLeft(1).
+ // Background(t.TextMuted()).
+ // Foreground(t.Text())
+ // for _, attachment := range msg.BinaryContent() {
+ // file := filepath.Base(attachment.Path)
+ // var filename string
+ // if len(file) > 10 {
+ // filename = fmt.Sprintf(" %s %s...", styles.DocumentIcon, file[0:7])
+ // } else {
+ // filename = fmt.Sprintf(" %s %s", styles.DocumentIcon, file)
+ // }
+ // styledAttachments = append(styledAttachments, attachmentStyles.Render(filename))
+ // }
+
+ timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
+ if time.Now().Format("02 Jan 2006") == timestamp[:11] {
+ timestamp = timestamp[12:]
+ }
+ info := styles.Padded().
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("%s (%s)", user, timestamp))
+
+ content := ""
+ // if len(styledAttachments) > 0 {
+ // attachmentContent := baseStyle.Width(width).Render(lipgloss.JoinHorizontal(lipgloss.Left, styledAttachments...))
+ // content = renderMessage(msg.Content().String(), true, isFocused, width, append(info, attachmentContent)...)
+ // } else {
+ for _, p := range msg.Parts {
+ part, err := p.ValueByDiscriminator()
+ if err != nil {
+ continue //TODO: handle error?
+ }
+
+ switch part.(type) {
+ case client.MessagePartText:
+ textPart := part.(client.MessagePartText)
+ text := toMarkdown(textPart.Text, width)
+ content = style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
+ }
+ }
+
+ return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+}
+
+func convertToMap(input *any) (map[string]any, bool) {
+ if input == nil {
+ return nil, false // Handle nil pointer
+ }
+ value := *input // Dereference the pointer to get the interface value
+ m, ok := value.(map[string]any) // Type assertion
+ return m, ok
+}
+
+func renderAssistantMessage(
+ msg client.MessageInfo,
+ width int,
+ showToolMessages bool,
+) string {
+ t := theme.CurrentTheme()
+ style := styles.BaseStyle().
+ BorderLeft(true).
+ Foreground(t.TextMuted()).
+ BorderForeground(t.Primary()).
+ BorderStyle(lipgloss.ThickBorder())
+ messages := []string{}
+
+ timestamp := time.UnixMilli(int64(msg.Metadata.Time.Created)).Local().Format("02 Jan 2006 03:04 PM")
+ if time.Now().Format("02 Jan 2006") == timestamp[:11] {
+ timestamp = timestamp[12:]
+ }
+ modelName := msg.Metadata.Assistant.ModelID
+ info := styles.Padded().
+ Foreground(t.TextMuted()).
+ Render(fmt.Sprintf("%s (%s)", modelName, timestamp))
+
+ for _, p := range msg.Parts {
+ part, err := p.ValueByDiscriminator()
+ if err != nil {
+ continue //TODO: handle error?
+ }
+
+ switch part.(type) {
+ // case client.MessagePartReasoning:
+ // reasoningPart := part.(client.MessagePartReasoning)
+
+ case client.MessagePartText:
+ textPart := part.(client.MessagePartText)
+ text := toMarkdown(textPart.Text, width)
+ content := style.Render(lipgloss.JoinVertical(lipgloss.Left, text, info))
+ message := styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ messages = append(messages, message)
+
+ case client.MessagePartToolInvocation:
+ if !showToolMessages {
+ continue
+ }
+
+ toolInvocationPart := part.(client.MessagePartToolInvocation)
+ toolCall, _ := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolCall()
+ var result *string
+ resultPart, resultError := toolInvocationPart.ToolInvocation.AsMessageToolInvocationToolResult()
+ if resultError == nil {
+ result = &resultPart.Result
+ }
+ metadata := map[string]any{}
+ if _, ok := msg.Metadata.Tool[toolCall.ToolCallId]; ok {
+ metadata = msg.Metadata.Tool[toolCall.ToolCallId].(map[string]any)
+ }
+ message := renderToolInvocation(toolCall, result, metadata, width)
+ messages = append(messages, message)
+ }
+ }
+
+ return strings.Join(messages, "\n\n")
+}
+
+func renderToolInvocation(toolCall client.MessageToolInvocationToolCall, result *string, metadata map[string]any, width int) string {
+ t := theme.CurrentTheme()
+ style := styles.BaseStyle().
+ BorderLeft(true).
+ Foreground(t.TextMuted()).
+ BorderForeground(t.TextMuted()).
+ BorderStyle(lipgloss.ThickBorder())
+
+ toolName := renderToolName(toolCall.ToolName)
+ var toolArgs []string
+ toolMap, _ := convertToMap(toolCall.Args)
+ for _, arg := range toolMap {
+ toolArgs = append(toolArgs, fmt.Sprintf("%v", arg))
+ }
+ params := renderParams(width-lipgloss.Width(toolName)-1, toolArgs...)
+ title := styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, params))
+ finished := result != nil
+ body := styles.Padded().Render("In progress...")
+ if finished {
+ body = *result
+ }
+
+ if toolCall.ToolName == "opencode_edit" {
+ filename := toolMap["filePath"].(string)
+ title = styles.Padded().Render(fmt.Sprintf("%s: %s", toolName, filename))
+ // oldString := toolMap["oldString"].(string)
+ // newString := toolMap["newString"].(string)
+ if finished {
+ patch := metadata["diff"].(string)
+ formattedDiff, _ := diff.FormatDiff(patch, diff.WithTotalWidth(width))
+ body = strings.TrimSpace(formattedDiff)
+ }
+
+ return style.Render(lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ body,
+ ))
+ } else if toolCall.ToolName == "opencode_view" {
+ filename := toolMap["filePath"].(string)
+ ext := filepath.Ext(filename)
+ if ext == "" {
+ ext = ""
+ } else {
+ ext = strings.ToLower(ext[1:])
+ }
+ if finished {
+ if metadata["preview"] != nil {
+ body = metadata["preview"].(string)
+ }
+ body = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(body, 10))
+ body = toMarkdown(body, width)
+ }
+ content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ body,
+ ))
+ return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+ }
+
+ // Default rendering
+ if finished {
+ body = styles.Padded().Render(truncateHeight(strings.TrimSpace(body), 10))
+ body = toMarkdown(body, width)
+ }
+ content := style.Render(lipgloss.JoinVertical(lipgloss.Left,
+ title,
+ body,
+ ))
+ return styles.ForceReplaceBackgroundWithLipgloss(content, t.Background())
+}
+
+func renderToolName(name string) string {
+ switch name {
+ // case agent.AgentToolName:
+ // return "Task"
+ case "opencode_ls":
+ return "List"
+ default:
+ normalizedName := name
+ if strings.HasPrefix(name, "opencode_") {
+ normalizedName = strings.TrimPrefix(name, "opencode_")
+ }
+
+ return cases.Title(language.Und).String(normalizedName)
+ }
+}
+
+func renderToolAction(name string) string {
+ switch name {
+ // case agent.AgentToolName:
+ // return "Preparing prompt..."
+ case "opencode_bash":
+ return "Building command..."
+ case "opencode_edit":
+ return "Preparing edit..."
+ case "opencode_fetch":
+ return "Writing fetch..."
+ case "opencode_glob":
+ return "Finding files..."
+ case "opencode_grep":
+ return "Searching content..."
+ case "opencode_ls":
+ return "Listing directory..."
+ case "opencode_view":
+ return "Reading file..."
+ case "opencode_write":
+ return "Preparing write..."
+ case "opencode_patch":
+ return "Preparing patch..."
+ case "opencode_batch":
+ return "Running batch operations..."
+ }
+ return "Working..."
+}
+
+// renders params, params[0] (params[1]=params[2] ....)
+func renderParams(paramsWidth int, params ...string) string {
+ if len(params) == 0 {
+ return ""
+ }
+ mainParam := params[0]
+ if len(mainParam) > paramsWidth {
+ mainParam = mainParam[:paramsWidth-3] + "..."
+ }
+
+ if len(params) == 1 {
+ return mainParam
+ }
+ otherParams := params[1:]
+ // create pairs of key/value
+ // if odd number of params, the last one is a key without value
+ if len(otherParams)%2 != 0 {
+ otherParams = append(otherParams, "")
+ }
+ parts := make([]string, 0, len(otherParams)/2)
+ for i := 0; i < len(otherParams); i += 2 {
+ key := otherParams[i]
+ value := otherParams[i+1]
+ if value == "" {
+ continue
+ }
+ parts = append(parts, fmt.Sprintf("%s=%s", key, value))
+ }
+
+ partsRendered := strings.Join(parts, ", ")
+ remainingWidth := paramsWidth - lipgloss.Width(partsRendered) - 5 // for the space
+ if remainingWidth < 30 {
+ // No space for the params, just show the main
+ return mainParam
+ }
+
+ if len(parts) > 0 {
+ mainParam = fmt.Sprintf("%s (%s)", mainParam, strings.Join(parts, ", "))
+ }
+
+ return ansi.Truncate(mainParam, paramsWidth, "...")
+}
+
+func renderToolParams(paramWidth int, toolCall any) string {
+ params := ""
+ switch toolCall {
+ // // case agent.AgentToolName:
+ // // var params agent.AgentParams
+ // // json.Unmarshal([]byte(toolCall.Input), &params)
+ // // prompt := strings.ReplaceAll(params.Prompt, "\n", " ")
+ // // return renderParams(paramWidth, prompt)
+ // case "bash":
+ // var params tools.BashParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // command := strings.ReplaceAll(params.Command, "\n", " ")
+ // return renderParams(paramWidth, command)
+ // case "edit":
+ // var params tools.EditParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // filePath := removeWorkingDirPrefix(params.FilePath)
+ // return renderParams(paramWidth, filePath)
+ // case "fetch":
+ // var params tools.FetchParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // url := params.URL
+ // toolParams := []string{
+ // url,
+ // }
+ // if params.Format != "" {
+ // toolParams = append(toolParams, "format", params.Format)
+ // }
+ // if params.Timeout != 0 {
+ // toolParams = append(toolParams, "timeout", (time.Duration(params.Timeout) * time.Second).String())
+ // }
+ // return renderParams(paramWidth, toolParams...)
+ // case tools.GlobToolName:
+ // var params tools.GlobParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // pattern := params.Pattern
+ // toolParams := []string{
+ // pattern,
+ // }
+ // if params.Path != "" {
+ // toolParams = append(toolParams, "path", params.Path)
+ // }
+ // return renderParams(paramWidth, toolParams...)
+ // case tools.GrepToolName:
+ // var params tools.GrepParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // pattern := params.Pattern
+ // toolParams := []string{
+ // pattern,
+ // }
+ // if params.Path != "" {
+ // toolParams = append(toolParams, "path", params.Path)
+ // }
+ // if params.Include != "" {
+ // toolParams = append(toolParams, "include", params.Include)
+ // }
+ // if params.LiteralText {
+ // toolParams = append(toolParams, "literal", "true")
+ // }
+ // return renderParams(paramWidth, toolParams...)
+ // case tools.LSToolName:
+ // var params tools.LSParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // path := params.Path
+ // if path == "" {
+ // path = "."
+ // }
+ // return renderParams(paramWidth, path)
+ // case tools.ViewToolName:
+ // var params tools.ViewParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // filePath := removeWorkingDirPrefix(params.FilePath)
+ // toolParams := []string{
+ // filePath,
+ // }
+ // if params.Limit != 0 {
+ // toolParams = append(toolParams, "limit", fmt.Sprintf("%d", params.Limit))
+ // }
+ // if params.Offset != 0 {
+ // toolParams = append(toolParams, "offset", fmt.Sprintf("%d", params.Offset))
+ // }
+ // return renderParams(paramWidth, toolParams...)
+ // case tools.WriteToolName:
+ // var params tools.WriteParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // filePath := removeWorkingDirPrefix(params.FilePath)
+ // return renderParams(paramWidth, filePath)
+ // case tools.BatchToolName:
+ // var params tools.BatchParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // return renderParams(paramWidth, fmt.Sprintf("%d parallel calls", len(params.Calls)))
+ // default:
+ // input := strings.ReplaceAll(toolCall, "\n", " ")
+ // params = renderParams(paramWidth, input)
+ }
+ return params
+}
+
+func truncateHeight(content string, height int) string {
+ lines := strings.Split(content, "\n")
+ if len(lines) > height {
+ return strings.Join(lines[:height], "\n")
+ }
+ return content
+}
+
+func renderToolResponse(toolCall any, response any, width int) string {
+ return ""
+ // t := theme.CurrentTheme()
+ // baseStyle := styles.BaseStyle()
+ //
+ // if response.IsError {
+ // errContent := fmt.Sprintf("Error: %s", strings.ReplaceAll(response.Content, "\n", " "))
+ // errContent = ansi.Truncate(errContent, width-1, "...")
+ // return baseStyle.
+ // Width(width).
+ // Foreground(t.Error()).
+ // Render(errContent)
+ // }
+ //
+ // resultContent := truncateHeight(response.Content, maxResultHeight)
+ // switch toolCall.Name {
+ // case agent.AgentToolName:
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, false, width),
+ // t.Background(),
+ // )
+ // case tools.BashToolName:
+ // resultContent = fmt.Sprintf("```bash\n%s\n```", resultContent)
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, width),
+ // t.Background(),
+ // )
+ // case tools.EditToolName:
+ // metadata := tools.EditResponseMetadata{}
+ // json.Unmarshal([]byte(response.Metadata), &metadata)
+ // formattedDiff, _ := diff.FormatDiff(metadata.Diff, diff.WithTotalWidth(width))
+ // return formattedDiff
+ // case tools.FetchToolName:
+ // var params tools.FetchParams
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // mdFormat := "markdown"
+ // switch params.Format {
+ // case "text":
+ // mdFormat = "text"
+ // case "html":
+ // mdFormat = "html"
+ // }
+ // resultContent = fmt.Sprintf("```%s\n%s\n```", mdFormat, resultContent)
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, width),
+ // t.Background(),
+ // )
+ // case tools.GlobToolName:
+ // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
+ // case tools.GrepToolName:
+ // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
+ // case tools.LSToolName:
+ // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(resultContent)
+ // case tools.ViewToolName:
+ // metadata := tools.ViewResponseMetadata{}
+ // json.Unmarshal([]byte(response.Metadata), &metadata)
+ // ext := filepath.Ext(metadata.FilePath)
+ // if ext == "" {
+ // ext = ""
+ // } else {
+ // ext = strings.ToLower(ext[1:])
+ // }
+ // resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(metadata.Content, maxResultHeight))
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, width),
+ // t.Background(),
+ // )
+ // case tools.WriteToolName:
+ // params := tools.WriteParams{}
+ // json.Unmarshal([]byte(toolCall.Input), &params)
+ // metadata := tools.WriteResponseMetadata{}
+ // json.Unmarshal([]byte(response.Metadata), &metadata)
+ // ext := filepath.Ext(params.FilePath)
+ // if ext == "" {
+ // ext = ""
+ // } else {
+ // ext = strings.ToLower(ext[1:])
+ // }
+ // resultContent = fmt.Sprintf("```%s\n%s\n```", ext, truncateHeight(params.Content, maxResultHeight))
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, width),
+ // t.Background(),
+ // )
+ // case tools.BatchToolName:
+ // var batchResult tools.BatchResult
+ // if err := json.Unmarshal([]byte(resultContent), &batchResult); err != nil {
+ // return baseStyle.Width(width).Foreground(t.Error()).Render(fmt.Sprintf("Error parsing batch result: %s", err))
+ // }
+ //
+ // var toolCalls []string
+ // for i, result := range batchResult.Results {
+ // toolName := renderToolName(result.ToolName)
+ //
+ // // Format the tool input as a string
+ // inputStr := string(result.ToolInput)
+ //
+ // // Format the result
+ // var resultStr string
+ // if result.Error != "" {
+ // resultStr = fmt.Sprintf("Error: %s", result.Error)
+ // } else {
+ // var toolResponse tools.ToolResponse
+ // if err := json.Unmarshal(result.Result, &toolResponse); err != nil {
+ // resultStr = "Error parsing tool response"
+ // } else {
+ // resultStr = truncateHeight(toolResponse.Content, 3)
+ // }
+ // }
+ //
+ // // Format the tool call
+ // toolCall := fmt.Sprintf("%d. %s: %s\n %s", i+1, toolName, inputStr, resultStr)
+ // toolCalls = append(toolCalls, toolCall)
+ // }
+ //
+ // return baseStyle.Width(width).Foreground(t.TextMuted()).Render(strings.Join(toolCalls, "\n\n"))
+ // default:
+ // resultContent = fmt.Sprintf("```text\n%s\n```", resultContent)
+ // return styles.ForceReplaceBackgroundWithLipgloss(
+ // toMarkdown(resultContent, width),
+ // t.Background(),
+ // )
+ // }
+}
+
+// func renderToolMessage(
+// toolCall message.ToolCall,
+// allMessages []message.Message,
+// messagesService message.Service,
+// focusedUIMessageId string,
+// nested bool,
+// width int,
+// position int,
+// ) string {
+// if nested {
+// width = width - 3
+// }
+//
+// t := theme.CurrentTheme()
+// baseStyle := styles.BaseStyle()
+//
+// style := baseStyle.
+// Width(width - 1).
+// BorderLeft(true).
+// BorderStyle(lipgloss.ThickBorder()).
+// PaddingLeft(1).
+// BorderForeground(t.TextMuted())
+//
+// response := findToolResponse(toolCall.ID, allMessages)
+// toolNameText := baseStyle.Foreground(t.TextMuted()).
+// Render(fmt.Sprintf("%s: ", renderToolName(toolCall.Name)))
+//
+// if !toolCall.Finished {
+// // Get a brief description of what the tool is doing
+// toolAction := renderToolAction(toolCall.Name)
+//
+// progressText := baseStyle.
+// Width(width - 2 - lipgloss.Width(toolNameText)).
+// Foreground(t.TextMuted()).
+// Render(fmt.Sprintf("%s", toolAction))
+//
+// content := style.Render(lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, progressText))
+// return content
+// }
+//
+// params := renderToolParams(width-1-lipgloss.Width(toolNameText), toolCall)
+// responseContent := ""
+// if response != nil {
+// responseContent = renderToolResponse(toolCall, *response, width-2)
+// responseContent = strings.TrimSuffix(responseContent, "\n")
+// } else {
+// responseContent = baseStyle.
+// Italic(true).
+// Width(width - 2).
+// Foreground(t.TextMuted()).
+// Render("Waiting for response...")
+// }
+//
+// parts := []string{}
+// if !nested {
+// formattedParams := baseStyle.
+// Width(width - 2 - lipgloss.Width(toolNameText)).
+// Foreground(t.TextMuted()).
+// Render(params)
+//
+// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, toolNameText, formattedParams))
+// } else {
+// prefix := baseStyle.
+// Foreground(t.TextMuted()).
+// Render(" └ ")
+// formattedParams := baseStyle.
+// Width(width - 2 - lipgloss.Width(toolNameText)).
+// Foreground(t.TextMuted()).
+// Render(params)
+// parts = append(parts, lipgloss.JoinHorizontal(lipgloss.Left, prefix, toolNameText, formattedParams))
+// }
+//
+// // if toolCall.Name == agent.AgentToolName {
+// // taskMessages, _ := messagesService.List(context.Background(), toolCall.ID)
+// // toolCalls := []message.ToolCall{}
+// // for _, v := range taskMessages {
+// // toolCalls = append(toolCalls, v.ToolCalls()...)
+// // }
+// // for _, call := range toolCalls {
+// // rendered := renderToolMessage(call, []message.Message{}, messagesService, focusedUIMessageId, true, width, 0)
+// // parts = append(parts, rendered.content)
+// // }
+// // }
+// if responseContent != "" && !nested {
+// parts = append(parts, responseContent)
+// }
+//
+// content := style.Render(
+// lipgloss.JoinVertical(
+// lipgloss.Left,
+// parts...,
+// ),
+// )
+// if nested {
+// content = lipgloss.JoinVertical(
+// lipgloss.Left,
+// parts...,
+// )
+// }
+// return content
+// }
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
new file mode 100644
index 000000000..e18b6d153
--- /dev/null
+++ b/packages/tui/internal/components/chat/messages.go
@@ -0,0 +1,344 @@
+package chat
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/charmbracelet/bubbles/key"
+ "github.com/charmbracelet/bubbles/spinner"
+ "github.com/charmbracelet/bubbles/viewport"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/components/dialog"
+ "github.com/sst/opencode/internal/state"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+ "github.com/sst/opencode/pkg/client"
+)
+
+type messagesCmp struct {
+ app *app.App
+ width, height int
+ viewport viewport.Model
+ spinner spinner.Model
+ rendering bool
+ attachments viewport.Model
+ showToolMessages bool
+}
+type renderFinishedMsg struct{}
+type ToggleToolMessagesMsg struct{}
+
+type MessageKeys struct {
+ PageDown key.Binding
+ PageUp key.Binding
+ HalfPageUp key.Binding
+ HalfPageDown key.Binding
+}
+
+var messageKeys = MessageKeys{
+ PageDown: key.NewBinding(
+ key.WithKeys("pgdown"),
+ key.WithHelp("f/pgdn", "page down"),
+ ),
+ PageUp: key.NewBinding(
+ key.WithKeys("pgup"),
+ key.WithHelp("b/pgup", "page up"),
+ ),
+ HalfPageUp: key.NewBinding(
+ key.WithKeys("ctrl+u"),
+ key.WithHelp("ctrl+u", "½ page up"),
+ ),
+ HalfPageDown: key.NewBinding(
+ key.WithKeys("ctrl+d", "ctrl+d"),
+ key.WithHelp("ctrl+d", "½ page down"),
+ ),
+}
+
+func (m *messagesCmp) Init() tea.Cmd {
+ return tea.Batch(m.viewport.Init(), m.spinner.Tick)
+}
+
+func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ var cmds []tea.Cmd
+ switch msg := msg.(type) {
+ case dialog.ThemeChangedMsg:
+ m.renderView()
+ return m, nil
+ case ToggleToolMessagesMsg:
+ m.showToolMessages = !m.showToolMessages
+ m.renderView()
+ return m, nil
+ case state.SessionSelectedMsg:
+ cmd := m.Reload()
+ return m, cmd
+ case state.SessionClearedMsg:
+ cmd := m.Reload()
+ return m, cmd
+ case tea.KeyMsg:
+ if key.Matches(msg, messageKeys.PageUp) || key.Matches(msg, messageKeys.PageDown) ||
+ key.Matches(msg, messageKeys.HalfPageUp) || key.Matches(msg, messageKeys.HalfPageDown) {
+ u, cmd := m.viewport.Update(msg)
+ m.viewport = u
+ cmds = append(cmds, cmd)
+ }
+ case renderFinishedMsg:
+ m.rendering = false
+ m.viewport.GotoBottom()
+ case state.StateUpdatedMsg:
+ m.renderView()
+ m.viewport.GotoBottom()
+ }
+
+ spinner, cmd := m.spinner.Update(msg)
+ m.spinner = spinner
+ cmds = append(cmds, cmd)
+ return m, tea.Batch(cmds...)
+}
+
+func (m *messagesCmp) renderView() {
+ if m.width == 0 {
+ return
+ }
+
+ messages := make([]string, 0)
+ for _, msg := range m.app.Messages {
+ switch msg.Role {
+ case client.User:
+ content := renderUserMessage(m.app.Info.User, msg, m.width)
+ messages = append(messages, content+"\n")
+ case client.Assistant:
+ content := renderAssistantMessage(msg, m.width, m.showToolMessages)
+ messages = append(messages, content+"\n")
+ }
+ }
+
+ m.viewport.SetContent(
+ styles.BaseStyle().
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ messages...,
+ ),
+ ),
+ )
+}
+
+func (m *messagesCmp) View() string {
+ baseStyle := styles.BaseStyle()
+
+ if m.rendering {
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ "Loading...",
+ m.working(),
+ m.help(),
+ ),
+ )
+ }
+
+ if len(m.app.Messages) == 0 {
+ content := baseStyle.
+ Width(m.width).
+ Height(m.height - 1).
+ Render(
+ m.initialScreen(),
+ )
+
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ content,
+ "",
+ m.help(),
+ ),
+ )
+ }
+
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ m.viewport.View(),
+ m.working(),
+ m.help(),
+ ),
+ )
+}
+
+// func hasToolsWithoutResponse(messages []message.Message) bool {
+// toolCalls := make([]message.ToolCall, 0)
+// toolResults := make([]message.ToolResult, 0)
+// for _, m := range messages {
+// toolCalls = append(toolCalls, m.ToolCalls()...)
+// toolResults = append(toolResults, m.ToolResults()...)
+// }
+//
+// for _, v := range toolCalls {
+// found := false
+// for _, r := range toolResults {
+// if v.ID == r.ToolCallID {
+// found = true
+// break
+// }
+// }
+// if !found && v.Finished {
+// return true
+// }
+// }
+// return false
+// }
+
+// func hasUnfinishedToolCalls(messages []message.Message) bool {
+// toolCalls := make([]message.ToolCall, 0)
+// for _, m := range messages {
+// toolCalls = append(toolCalls, m.ToolCalls()...)
+// }
+// for _, v := range toolCalls {
+// if !v.Finished {
+// return true
+// }
+// }
+// return false
+// }
+
+func (m *messagesCmp) working() string {
+ text := ""
+ if len(m.app.Messages) > 0 {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ task := ""
+
+ lastMessage := m.app.Messages[len(m.app.Messages)-1]
+ if lastMessage.Metadata.Time.Completed == nil {
+ task = "Working..."
+ }
+ // lastMessage := m.app.Messages[len(m.app.Messages)-1]
+ // if hasToolsWithoutResponse(m.app.Messages) {
+ // task = "Waiting for tool response..."
+ // } else if hasUnfinishedToolCalls(m.app.Messages) {
+ // task = "Building tool call..."
+ // } else if !lastMessage.IsFinished() {
+ // task = "Generating..."
+ // }
+ if task != "" {
+ text += baseStyle.
+ Width(m.width).
+ Foreground(t.Primary()).
+ Bold(true).
+ Render(fmt.Sprintf("%s %s ", m.spinner.View(), task))
+ }
+ }
+ return text
+}
+
+func (m *messagesCmp) help() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ text := ""
+
+ // if m.app.PrimaryAgentOLD.IsBusy() {
+ // text += lipgloss.JoinHorizontal(
+ // lipgloss.Left,
+ // baseStyle.Foreground(t.TextMuted()).Bold(true).Render("press "),
+ // baseStyle.Foreground(t.Text()).Bold(true).Render("esc"),
+ // baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to interrupt"),
+ // )
+ // } else {
+ text += lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to send,"),
+ baseStyle.Foreground(t.Text()).Bold(true).Render(" \\"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render("+"),
+ baseStyle.Foreground(t.Text()).Bold(true).Render("enter"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
+ baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
+ baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
+ baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
+ )
+ // }
+ return baseStyle.
+ Width(m.width).
+ Render(text)
+}
+
+func (m *messagesCmp) initialScreen() string {
+ baseStyle := styles.BaseStyle()
+
+ return baseStyle.Width(m.width).Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.app, m.width),
+ "",
+ lspsConfigured(m.width),
+ ),
+ )
+}
+
+func (m *messagesCmp) SetSize(width, height int) tea.Cmd {
+ if m.width == width && m.height == height {
+ return nil
+ }
+ m.width = width
+ m.height = height
+ m.viewport.Width = width
+ m.viewport.Height = height - 2
+ m.attachments.Width = width + 40
+ m.attachments.Height = 3
+ m.renderView()
+ return nil
+}
+
+func (m *messagesCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func (m *messagesCmp) Reload() tea.Cmd {
+ m.rendering = true
+ return func() tea.Msg {
+ m.renderView()
+ return renderFinishedMsg{}
+ }
+}
+
+func (m *messagesCmp) BindingKeys() []key.Binding {
+ return []key.Binding{
+ m.viewport.KeyMap.PageDown,
+ m.viewport.KeyMap.PageUp,
+ m.viewport.KeyMap.HalfPageUp,
+ m.viewport.KeyMap.HalfPageDown,
+ }
+}
+
+func NewMessagesCmp(app *app.App) tea.Model {
+ customSpinner := spinner.Spinner{
+ Frames: []string{" ", "┃", "┃"},
+ FPS: time.Second / 3,
+ }
+ s := spinner.New(spinner.WithSpinner(customSpinner))
+
+ vp := viewport.New(0, 0)
+ attachments := viewport.New(0, 0)
+ vp.KeyMap.PageUp = messageKeys.PageUp
+ vp.KeyMap.PageDown = messageKeys.PageDown
+ vp.KeyMap.HalfPageUp = messageKeys.HalfPageUp
+ vp.KeyMap.HalfPageDown = messageKeys.HalfPageDown
+
+ return &messagesCmp{
+ app: app,
+ viewport: vp,
+ spinner: s,
+ attachments: attachments,
+ showToolMessages: true,
+ }
+}
diff --git a/packages/tui/internal/components/chat/sidebar.go b/packages/tui/internal/components/chat/sidebar.go
new file mode 100644
index 000000000..b2fe872ea
--- /dev/null
+++ b/packages/tui/internal/components/chat/sidebar.go
@@ -0,0 +1,212 @@
+package chat
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+ "github.com/sst/opencode/internal/app"
+ "github.com/sst/opencode/internal/state"
+ "github.com/sst/opencode/internal/styles"
+ "github.com/sst/opencode/internal/theme"
+)
+
+type sidebarCmp struct {
+ app *app.App
+ width, height int
+ modFiles map[string]struct {
+ additions int
+ removals int
+ }
+}
+
+func (m *sidebarCmp) Init() tea.Cmd {
+ // TODO: History service not implemented in API yet
+ // Initialize the modified files map
+ m.modFiles = make(map[string]struct {
+ additions int
+ removals int
+ })
+ return nil
+}
+
+func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg.(type) {
+ case state.SessionSelectedMsg:
+ // TODO: History service not implemented in API yet
+ // ctx := context.Background()
+ // m.loadModifiedFiles(ctx)
+ // case pubsub.Event[history.File]:
+ // TODO: History service not implemented in API yet
+ // if msg.Payload.SessionID == m.app.CurrentSession.ID {
+ // // Process the individual file change instead of reloading all files
+ // ctx := context.Background()
+ // m.processFileChanges(ctx, msg.Payload)
+ // }
+ }
+ return m, nil
+}
+
+func (m *sidebarCmp) View() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+ shareUrl := ""
+ if m.app.Session.Share != nil {
+ shareUrl = baseStyle.Foreground(t.TextMuted()).Render(m.app.Session.Share.Url)
+ }
+
+ // qrcode := ""
+ // if m.app.Session.ShareID != nil {
+ // url := "https://dev.opencode.ai/share?id="
+ // qrcode, _, _ = qr.Generate(url + m.app.Session.Id)
+ // }
+
+ return baseStyle.
+ Width(m.width).
+ PaddingLeft(4).
+ PaddingRight(1).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ header(m.app, m.width),
+ " ",
+ m.sessionSection(),
+ shareUrl,
+ ),
+ )
+}
+
+func (m *sidebarCmp) sessionSection() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ sessionKey := baseStyle.
+ Foreground(t.Primary()).
+ Bold(true).
+ Render("Session")
+
+ sessionValue := baseStyle.
+ Foreground(t.Text()).
+ Render(fmt.Sprintf(": %s", m.app.Session.Title))
+
+ return sessionKey + sessionValue
+}
+
+func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ stats := ""
+ if additions > 0 && removals > 0 {
+ additionsStr := baseStyle.
+ Foreground(t.Success()).
+ PaddingLeft(1).
+ Render(fmt.Sprintf("+%d", additions))
+
+ removalsStr := baseStyle.
+ Foreground(t.Error()).
+ PaddingLeft(1).
+ Render(fmt.Sprintf("-%d", removals))
+
+ content := lipgloss.JoinHorizontal(lipgloss.Left, additionsStr, removalsStr)
+ stats = baseStyle.Width(lipgloss.Width(content)).Render(content)
+ } else if additions > 0 {
+ additionsStr := fmt.Sprintf(" %s", baseStyle.
+ PaddingLeft(1).
+ Foreground(t.Success()).
+ Render(fmt.Sprintf("+%d", additions)))
+ stats = baseStyle.Width(lipgloss.Width(additionsStr)).Render(additionsStr)
+ } else if removals > 0 {
+ removalsStr := fmt.Sprintf(" %s", baseStyle.
+ PaddingLeft(1).
+ Foreground(t.Error()).
+ Render(fmt.Sprintf("-%d", removals)))
+ stats = baseStyle.Width(lipgloss.Width(removalsStr)).Render(removalsStr)
+ }
+
+ filePathStr := baseStyle.Render(filePath)
+
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinHorizontal(
+ lipgloss.Left,
+ filePathStr,
+ stats,
+ ),
+ )
+}
+
+func (m *sidebarCmp) modifiedFiles() string {
+ t := theme.CurrentTheme()
+ baseStyle := styles.BaseStyle()
+
+ modifiedFiles := baseStyle.
+ Width(m.width).
+ Foreground(t.Primary()).
+ Bold(true).
+ Render("Modified Files:")
+
+ // If no modified files, show a placeholder message
+ if m.modFiles == nil || len(m.modFiles) == 0 {
+ message := "No modified files"
+ remainingWidth := m.width - lipgloss.Width(message)
+ if remainingWidth > 0 {
+ message += strings.Repeat(" ", remainingWidth)
+ }
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ baseStyle.Foreground(t.TextMuted()).Render(message),
+ ),
+ )
+ }
+
+ // Sort file paths alphabetically for consistent ordering
+ var paths []string
+ for path := range m.modFiles {
+ paths = append(paths, path)
+ }
+ sort.Strings(paths)
+
+ // Create views for each file in sorted order
+ var fileViews []string
+ for _, path := range paths {
+ stats := m.modFiles[path]
+ fileViews = append(fileViews, m.modifiedFile(path, stats.additions, stats.removals))
+ }
+
+ return baseStyle.
+ Width(m.width).
+ Render(
+ lipgloss.JoinVertical(
+ lipgloss.Top,
+ modifiedFiles,
+ lipgloss.JoinVertical(
+ lipgloss.Left,
+ fileViews...,
+ ),
+ ),
+ )
+}
+
+func (m *sidebarCmp) SetSize(width, height int) tea.Cmd {
+ m.width = width
+ m.height = height
+ return nil
+}
+
+func (m *sidebarCmp) GetSize() (int, int) {
+ return m.width, m.height
+}
+
+func NewSidebarCmp(app *app.App) tea.Model {
+ return &sidebarCmp{
+ app: app,
+ }
+}