diff options
| author | Dax Raad <[email protected]> | 2025-11-02 18:43:17 -0500 |
|---|---|---|
| committer | Dax Raad <[email protected]> | 2025-11-02 18:43:33 -0500 |
| commit | f68374ad2223ddc213bdea9519ca6a699819ee0e (patch) | |
| tree | 04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/chat | |
| parent | 5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff) | |
| download | opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.tar.gz opencode-f68374ad2223ddc213bdea9519ca6a699819ee0e.zip | |
DELETE GO BUBBLETEA CRAP HOORAY
Diffstat (limited to 'packages/tui/internal/components/chat')
| -rw-r--r-- | packages/tui/internal/components/chat/cache.go | 62 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/editor.go | 906 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/message.go | 1031 | ||||
| -rw-r--r-- | packages/tui/internal/components/chat/messages.go | 1322 |
4 files changed, 0 insertions, 3321 deletions
diff --git a/packages/tui/internal/components/chat/cache.go b/packages/tui/internal/components/chat/cache.go deleted file mode 100644 index 454f1a5a9..000000000 --- a/packages/tui/internal/components/chat/cache.go +++ /dev/null @@ -1,62 +0,0 @@ -package chat - -import ( - "encoding/hex" - "fmt" - "hash/fnv" - "sync" -) - -// PartCache caches rendered messages to avoid re-rendering -type PartCache struct { - mu sync.RWMutex - cache map[string]string -} - -// NewPartCache creates a new message cache -func NewPartCache() *PartCache { - return &PartCache{ - cache: make(map[string]string), - } -} - -// generateKey creates a unique key for a message based on its content and rendering parameters -func (c *PartCache) GenerateKey(params ...any) string { - h := fnv.New64a() - for _, param := range params { - h.Write(fmt.Appendf(nil, ":%v", param)) - } - return hex.EncodeToString(h.Sum(nil)) -} - -// Get retrieves a cached rendered message -func (c *PartCache) Get(key string) (string, bool) { - c.mu.RLock() - defer c.mu.RUnlock() - - content, exists := c.cache[key] - return content, exists -} - -// Set stores a rendered message in the cache -func (c *PartCache) Set(key string, content string) { - c.mu.Lock() - defer c.mu.Unlock() - c.cache[key] = content -} - -// Clear removes all entries from the cache -func (c *PartCache) Clear() { - c.mu.Lock() - defer c.mu.Unlock() - - c.cache = make(map[string]string) -} - -// Size returns the number of cached entries -func (c *PartCache) Size() int { - c.mu.RLock() - defer c.mu.RUnlock() - - return len(c.cache) -} diff --git a/packages/tui/internal/components/chat/editor.go b/packages/tui/internal/components/chat/editor.go deleted file mode 100644 index d3c813840..000000000 --- a/packages/tui/internal/components/chat/editor.go +++ /dev/null @@ -1,906 +0,0 @@ -package chat - -import ( - "encoding/base64" - "fmt" - "log/slog" - "os" - "path/filepath" - "strconv" - "strings" - "unicode/utf8" - - "github.com/charmbracelet/bubbles/v2/spinner" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/google/uuid" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/attachment" - "github.com/sst/opencode/internal/clipboard" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/dialog" - "github.com/sst/opencode/internal/components/textarea" - "github.com/sst/opencode/internal/components/toast" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" -) - -type EditorComponent interface { - tea.Model - tea.ViewModel - Content() string - Cursor() *tea.Cursor - Lines() int - Value() string - Length() int - Focused() bool - Focus() (tea.Model, tea.Cmd) - Blur() - Submit() (tea.Model, tea.Cmd) - SubmitBash() (tea.Model, tea.Cmd) - Clear() (tea.Model, tea.Cmd) - Paste() (tea.Model, tea.Cmd) - Newline() (tea.Model, tea.Cmd) - SetValue(value string) - SetValueWithAttachments(value string) - SetInterruptKeyInDebounce(inDebounce bool) - SetExitKeyInDebounce(inDebounce bool) - RestoreFromHistory(index int) - GetAttachments() []*attachment.Attachment -} - -type editorComponent struct { - app *app.App - width int - textarea textarea.Model - spinner spinner.Model - interruptKeyInDebounce bool - exitKeyInDebounce bool - historyIndex int // -1 means current (not in history) - currentText string // Store current text when navigating history - pasteCounter int - reverted bool -} - -func (m *editorComponent) Init() tea.Cmd { - return tea.Batch(m.textarea.Focus(), m.spinner.Tick, tea.EnableReportFocus) -} - -func (m *editorComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - var cmd tea.Cmd - - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - 4 - return m, nil - case spinner.TickMsg: - m.spinner, cmd = m.spinner.Update(msg) - return m, cmd - case tea.KeyPressMsg: - // Handle up/down arrows and ctrl+p/ctrl+n for history navigation - switch msg.String() { - case "up", "ctrl+p": - // Only navigate history if cursor is at the first line and column (for arrow keys) - // or allow ctrl+p from anywhere - if (msg.String() == "ctrl+p" || (m.textarea.Line() == 0 && m.textarea.CursorColumn() == 0)) && len(m.app.State.MessageHistory) > 0 { - if m.historyIndex == -1 { - // Save current text before entering history - m.currentText = m.textarea.Value() - m.textarea.MoveToBegin() - } - // Move up in history (older messages) - if m.historyIndex < len(m.app.State.MessageHistory)-1 { - m.historyIndex++ - m.RestoreFromHistory(m.historyIndex) - m.textarea.MoveToBegin() - } - return m, nil - } - case "down", "ctrl+n": - // Only navigate history if cursor is at the last line and we're in history navigation (for arrow keys) - // or allow ctrl+n from anywhere if we're in history navigation - if (msg.String() == "ctrl+n" || m.textarea.IsCursorAtEnd()) && m.historyIndex > -1 { - // Move down in history (newer messages) - m.historyIndex-- - if m.historyIndex == -1 { - // Restore current text - m.textarea.Reset() - m.textarea.SetValue(m.currentText) - m.currentText = "" - } else { - m.RestoreFromHistory(m.historyIndex) - m.textarea.MoveToEnd() - } - return m, nil - } else if m.historyIndex > -1 && msg.String() == "down" { - m.textarea.MoveToEnd() - return m, nil - } - } - // Reset history navigation on any other input - if m.historyIndex != -1 { - m.historyIndex = -1 - m.currentText = "" - } - // Maximize editor responsiveness for printable characters - if msg.Text != "" { - m.reverted = false - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - return m, tea.Batch(cmds...) - } - case app.MessageRevertedMsg: - if msg.Session.ID == m.app.Session.ID { - switch msg.Message.Info.(type) { - case opencode.UserMessage: - prompt, err := msg.Message.ToPrompt() - if err != nil { - return m, toast.NewErrorToast("Failed to revert message") - } - m.RestoreFromPrompt(*prompt) - m.textarea.MoveToEnd() - m.reverted = true - return m, nil - } - } - case app.SessionUnrevertedMsg: - if msg.Session.ID == m.app.Session.ID { - if m.reverted { - updated, cmd := m.Clear() - m = updated.(*editorComponent) - return m, cmd - } - return m, nil - } - case tea.PasteMsg: - text := string(msg) - - if filePath := strings.TrimSpace(strings.TrimPrefix(text, "@")); strings.HasPrefix(text, "@") && filePath != "" { - statPath := filePath - if !filepath.IsAbs(filePath) { - statPath = filepath.Join(util.CwdPath, filePath) - } - if _, err := os.Stat(statPath); err == nil { - attachment := m.createAttachmentFromPath(filePath) - if attachment != nil { - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - } - } - } - - text = strings.ReplaceAll(text, "\\", "") - text, err := strconv.Unquote(`"` + text + `"`) - if err != nil { - slog.Error("Failed to unquote text", "error", err) - text := string(msg) - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - if _, err := os.Stat(text); err != nil { - slog.Error("Failed to paste file", "error", err) - text := string(msg) - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - - filePath := text - - attachment := m.createAttachmentFromFile(filePath) - if attachment == nil { - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(msg)) - } - return m, nil - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - case tea.ClipboardMsg: - text := string(msg) - // Check if the pasted text is long and should be summarized - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(text)) - } - case dialog.ThemeSelectedMsg: - m.textarea = updateTextareaStyles(m.textarea) - m.spinner = createSpinner() - return m, tea.Batch(m.textarea.Focus(), m.spinner.Tick) - case dialog.CompletionSelectedMsg: - switch msg.Item.ProviderID { - case "commands": - command := msg.Item.RawData.(commands.Command) - if command.Custom { - m.SetValue("/" + command.PrimaryTrigger() + " ") - return m, nil - } - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - commandName := strings.TrimPrefix(msg.Item.Value, "/") - cmds = append(cmds, util.CmdHandler(commands.ExecuteCommandMsg(m.app.Commands[commands.CommandName(commandName)]))) - return m, tea.Batch(cmds...) - case "files": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - // The range to replace is from the '@' up to the current cursor position. - // Replace the search term (e.g., "@search") with an empty string first. - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - // Now, insert the attachment at the position where the '@' was. - // The cursor is now at `atIndex` after the replacement. - filePath := msg.Item.Value - attachment := m.createAttachmentFromPath(filePath) - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - case "symbols": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - symbol := msg.Item.RawData.(opencode.Symbol) - parts := strings.Split(symbol.Name, ".") - lastPart := parts[len(parts)-1] - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "symbol", - Display: "@" + lastPart, - URL: msg.Item.Value, - Filename: lastPart, - MediaType: "text/plain", - Source: &attachment.SymbolSource{ - Path: symbol.Location.Uri, - Name: symbol.Name, - Kind: int(symbol.Kind), - Range: attachment.SymbolRange{ - Start: attachment.Position{ - Line: int(symbol.Location.Range.Start.Line), - Char: int(symbol.Location.Range.Start.Character), - }, - End: attachment.Position{ - Line: int(symbol.Location.Range.End.Line), - Char: int(symbol.Location.Range.End.Character), - }, - }, - }, - } - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - case "agents": - atIndex := m.textarea.LastRuneIndex('@') - if atIndex == -1 { - // Should not happen, but as a fallback, just insert. - m.textarea.InsertString(msg.Item.Value + " ") - return m, nil - } - - cursorCol := m.textarea.CursorColumn() - m.textarea.ReplaceRange(atIndex, cursorCol, "") - - name := msg.Item.Value - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "agent", - Display: "@" + name, - Source: &attachment.AgentSource{ - Name: name, - }, - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - - default: - slog.Debug("Unknown provider", "provider", msg.Item.ProviderID) - return m, nil - } - } - - m.spinner, cmd = m.spinner.Update(msg) - cmds = append(cmds, cmd) - - m.textarea, cmd = m.textarea.Update(msg) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) Content() string { - width := m.width - if m.app.Session.ID == "" { - width = min(width, 80) - } - - t := theme.CurrentTheme() - base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render - muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render - - promptStyle := styles.NewStyle().Foreground(t.Primary()). - Padding(0, 0, 0, 1). - Bold(true) - prompt := promptStyle.Render(">") - borderForeground := t.Border() - if m.app.IsLeaderSequence { - borderForeground = t.Accent() - } - if m.app.IsBashMode { - borderForeground = t.Secondary() - prompt = promptStyle.Render("!") - } - - m.textarea.SetWidth(width - 6) - textarea := lipgloss.JoinHorizontal( - lipgloss.Top, - prompt, - m.textarea.View(), - ) - textarea = styles.NewStyle(). - Background(t.BackgroundElement()). - Width(width). - PaddingTop(1). - PaddingBottom(1). - BorderStyle(lipgloss.ThickBorder()). - BorderForeground(borderForeground). - BorderBackground(t.Background()). - BorderLeft(true). - BorderRight(true). - Render(textarea) - - hint := base(m.getSubmitKeyText()) + muted(" send ") - if m.exitKeyInDebounce { - keyText := m.getExitKeyText() - hint = base(keyText+" again") + muted(" to exit") - } else if m.app.IsBusy() { - keyText := m.getInterruptKeyText() - status := "working" - if m.app.IsCompacting() { - status = "compacting" - } - if m.app.CurrentPermission.ID != "" { - status = "waiting for permission" - } - if m.interruptKeyInDebounce && m.app.CurrentPermission.ID == "" { - hint = muted( - status, - ) + m.spinner.View() + muted( - " ", - ) + base( - keyText+" again", - ) + muted( - " interrupt", - ) - } else { - hint = muted(status) + m.spinner.View() - if m.app.CurrentPermission.ID == "" { - hint += muted(" ") + base(keyText) + muted(" interrupt") - } - } - } - - model := "" - if m.app.Model != nil { - model = muted(m.app.Provider.Name) + base(" "+m.app.Model.Name) - } - - space := width - 2 - lipgloss.Width(model) - lipgloss.Width(hint) - spacer := styles.NewStyle().Background(t.Background()).Width(space).Render("") - - info := hint + spacer + model - info = styles.NewStyle().Background(t.Background()).Padding(0, 1).Render(info) - - content := strings.Join([]string{"", textarea, info}, "\n") - return content -} - -func (m *editorComponent) Cursor() *tea.Cursor { - return m.textarea.Cursor() -} - -func (m *editorComponent) View() string { - width := m.width - if m.app.Session.ID == "" { - width = min(width, 80) - } - - if m.Lines() > 1 { - return lipgloss.Place( - width, - 5, - lipgloss.Center, - lipgloss.Center, - "", - styles.WhitespaceStyle(theme.CurrentTheme().Background()), - ) - } - return m.Content() -} - -func (m *editorComponent) Focused() bool { - return m.textarea.Focused() -} - -func (m *editorComponent) Focus() (tea.Model, tea.Cmd) { - return m, m.textarea.Focus() -} - -func (m *editorComponent) Blur() { - m.textarea.Blur() -} - -func (m *editorComponent) Lines() int { - return m.textarea.LineCount() -} - -func (m *editorComponent) Value() string { - return m.textarea.Value() -} - -func (m *editorComponent) Length() int { - return m.textarea.Length() -} - -func (m *editorComponent) GetAttachments() []*attachment.Attachment { - return m.textarea.GetAttachments() -} - -func (m *editorComponent) Submit() (tea.Model, tea.Cmd) { - value := strings.TrimSpace(m.Value()) - if value == "" { - return m, nil - } - - switch value { - case "exit", "quit", "q", ":q": - return m, tea.Quit - } - - if len(value) > 0 && value[len(value)-1] == '\\' { - // If the last character is a backslash, remove it and add a newline - backslashCol := m.textarea.CurrentRowLength() - 1 - m.textarea.ReplaceRange(backslashCol, backslashCol+1, "") - m.textarea.InsertString("\n") - return m, nil - } - - var cmds []tea.Cmd - if strings.HasPrefix(value, "/") { - // Expand attachments in the value to get actual content - expandedValue := value - attachments := m.textarea.GetAttachments() - for _, att := range attachments { - if att.Type == "text" && att.Source != nil { - if textSource, ok := att.Source.(*attachment.TextSource); ok { - expandedValue = strings.Replace(expandedValue, att.Display, textSource.Value, 1) - } - } - } - - expandedValue = expandedValue[1:] // Remove the "/" - commandName := strings.Split(expandedValue, " ")[0] - command := m.app.Commands[commands.CommandName(commandName)] - if command.Custom { - args := "" - if strings.HasPrefix(expandedValue, command.PrimaryTrigger()+" ") { - args = strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ") - } - cmds = append( - cmds, - util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}), - ) - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) - } - } - - attachments := m.textarea.GetAttachments() - - prompt := app.Prompt{Text: value, Attachments: attachments} - m.app.State.AddPromptToHistory(prompt) - cmds = append(cmds, m.app.SaveState()) - - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - - cmds = append(cmds, util.CmdHandler(app.SendPrompt(prompt))) - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) SubmitBash() (tea.Model, tea.Cmd) { - command := m.textarea.Value() - var cmds []tea.Cmd - updated, cmd := m.Clear() - m = updated.(*editorComponent) - cmds = append(cmds, cmd) - cmds = append(cmds, util.CmdHandler(app.SendShell{Command: command})) - return m, tea.Batch(cmds...) -} - -func (m *editorComponent) Clear() (tea.Model, tea.Cmd) { - m.textarea.Reset() - m.historyIndex = -1 - m.currentText = "" - m.pasteCounter = 0 - return m, nil -} - -func (m *editorComponent) Paste() (tea.Model, tea.Cmd) { - imageBytes := clipboard.Read(clipboard.FmtImage) - if imageBytes != nil { - attachmentCount := len(m.textarea.GetAttachments()) - attachmentIndex := attachmentCount + 1 - base64EncodedFile := base64.StdEncoding.EncodeToString(imageBytes) - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - MediaType: "image/png", - Display: fmt.Sprintf("[Image #%d]", attachmentIndex), - Filename: fmt.Sprintf("image-%d.png", attachmentIndex), - URL: fmt.Sprintf("data:image/png;base64,%s", base64EncodedFile), - Source: &attachment.FileSource{ - Path: fmt.Sprintf("image-%d.png", attachmentIndex), - Mime: "image/png", - Data: imageBytes, - }, - } - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") - return m, nil - } - - textBytes := clipboard.Read(clipboard.FmtText) - if textBytes != nil { - text := string(textBytes) - // Check if the pasted text is long and should be summarized - if m.shouldSummarizePastedText(text) { - m.handleLongPaste(text) - } else { - m.textarea.InsertRunesFromUserInput([]rune(text)) - } - return m, nil - } - - // fallback to reading the clipboard using OSC52 - return m, tea.ReadClipboard -} - -func (m *editorComponent) Newline() (tea.Model, tea.Cmd) { - m.textarea.Newline() - return m, nil -} - -func (m *editorComponent) SetInterruptKeyInDebounce(inDebounce bool) { - m.interruptKeyInDebounce = inDebounce -} - -func (m *editorComponent) SetValue(value string) { - m.textarea.SetValue(value) -} - -func (m *editorComponent) SetValueWithAttachments(value string) { - m.textarea.Reset() - - i := 0 - for i < len(value) { - r, size := utf8.DecodeRuneInString(value[i:]) - // Check if filepath and add attachment - if r == '@' { - start := i + size - end := start - for end < len(value) { - nextR, nextSize := utf8.DecodeRuneInString(value[end:]) - if nextR == ' ' || nextR == '\t' || nextR == '\n' || nextR == '\r' { - break - } - end += nextSize - } - if end > start { - filePath := value[start:end] - if _, err := os.Stat(filepath.Join(util.CwdPath, filePath)); err == nil { - attachment := m.createAttachmentFromFile(filePath) - if attachment != nil { - m.textarea.InsertAttachment(attachment) - i = end - continue - } - } - } - } - - // Not a valid file path, insert the character normally - m.textarea.InsertRune(r) - i += size - } -} - -func (m *editorComponent) SetExitKeyInDebounce(inDebounce bool) { - m.exitKeyInDebounce = inDebounce -} - -func (m *editorComponent) getInterruptKeyText() string { - return m.app.Commands[commands.SessionInterruptCommand].Keys()[0] -} - -func (m *editorComponent) getSubmitKeyText() string { - return m.app.Commands[commands.InputSubmitCommand].Keys()[0] -} - -func (m *editorComponent) getExitKeyText() string { - return m.app.Commands[commands.AppExitCommand].Keys()[0] -} - -// shouldSummarizePastedText determines if pasted text should be summarized -func (m *editorComponent) shouldSummarizePastedText(text string) bool { - if m.app.IsBashMode { - return false - } - - if m.app.Config != nil && m.app.Config.Experimental.DisablePasteSummary { - return false - } - - lines := strings.Split(text, "\n") - lineCount := len(lines) - charCount := len(text) - - // Consider text long if it has more than 3 lines or more than 150 characters - return lineCount > 3 || charCount > 150 -} - -// handleLongPaste handles long pasted text by creating a summary attachment -func (m *editorComponent) handleLongPaste(text string) { - lines := strings.Split(text, "\n") - lineCount := len(lines) - - // Increment paste counter - m.pasteCounter++ - - // Create attachment with full text as base64 encoded data - fileBytes := []byte(text) - base64EncodedText := base64.StdEncoding.EncodeToString(fileBytes) - url := fmt.Sprintf("data:text/plain;base64,%s", base64EncodedText) - - fileName := fmt.Sprintf("pasted-text-%d.txt", m.pasteCounter) - displayText := fmt.Sprintf("[pasted #%d %d+ lines]", m.pasteCounter, lineCount) - - attachment := &attachment.Attachment{ - ID: uuid.NewString(), - Type: "text", - MediaType: "text/plain", - Display: displayText, - URL: url, - Filename: fileName, - Source: &attachment.TextSource{ - Value: text, - }, - } - - m.textarea.InsertAttachment(attachment) - m.textarea.InsertString(" ") -} - -func updateTextareaStyles(ta textarea.Model) textarea.Model { - t := theme.CurrentTheme() - bgColor := t.BackgroundElement() - textColor := t.Text() - textMutedColor := t.TextMuted() - - ta.Styles.Blurred.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Blurred.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Blurred.Placeholder = styles.NewStyle(). - Foreground(textMutedColor). - Background(bgColor). - Lipgloss() - ta.Styles.Blurred.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Focused.Base = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Focused.CursorLine = styles.NewStyle().Background(bgColor).Lipgloss() - ta.Styles.Focused.Placeholder = styles.NewStyle(). - Foreground(textMutedColor). - Background(bgColor). - Lipgloss() - ta.Styles.Focused.Text = styles.NewStyle().Foreground(textColor).Background(bgColor).Lipgloss() - ta.Styles.Attachment = styles.NewStyle(). - Foreground(t.Secondary()). - Background(bgColor). - Lipgloss() - ta.Styles.SelectedAttachment = styles.NewStyle(). - Foreground(t.Text()). - Background(t.Secondary()). - Lipgloss() - ta.Styles.Cursor.Color = t.Primary() - return ta -} - -func createSpinner() spinner.Model { - t := theme.CurrentTheme() - return spinner.New( - spinner.WithSpinner(spinner.Ellipsis), - spinner.WithStyle( - styles.NewStyle(). - Background(t.Background()). - Foreground(t.TextMuted()). - Width(3). - Lipgloss(), - ), - ) -} - -func NewEditorComponent(app *app.App) EditorComponent { - s := createSpinner() - - ta := textarea.New() - ta.Prompt = " " - ta.ShowLineNumbers = false - ta.CharLimit = -1 - ta.VirtualCursor = false - ta = updateTextareaStyles(ta) - - m := &editorComponent{ - app: app, - textarea: ta, - spinner: s, - interruptKeyInDebounce: false, - historyIndex: -1, - pasteCounter: 0, - } - - return m -} - -func (m *editorComponent) RestoreFromPrompt(prompt app.Prompt) { - m.textarea.Reset() - m.textarea.SetValue(prompt.Text) - - // Sort attachments by start index in reverse order (process from end to beginning) - // This prevents index shifting issues - attachmentsCopy := make([]*attachment.Attachment, len(prompt.Attachments)) - copy(attachmentsCopy, prompt.Attachments) - - for i := 0; i < len(attachmentsCopy)-1; i++ { - for j := i + 1; j < len(attachmentsCopy); j++ { - if attachmentsCopy[i].StartIndex < attachmentsCopy[j].StartIndex { - attachmentsCopy[i], attachmentsCopy[j] = attachmentsCopy[j], attachmentsCopy[i] - } - } - } - - for _, att := range attachmentsCopy { - m.textarea.SetCursorColumn(att.StartIndex) - m.textarea.ReplaceRange(att.StartIndex, att.EndIndex, "") - m.textarea.InsertAttachment(att) - } -} - -// RestoreFromHistory restores a message from history at the given index -func (m *editorComponent) RestoreFromHistory(index int) { - if index < 0 || index >= len(m.app.State.MessageHistory) { - return - } - entry := m.app.State.MessageHistory[index] - m.RestoreFromPrompt(entry) -} - -func getMediaTypeFromExtension(ext string) string { - switch strings.ToLower(ext) { - case ".jpg": - return "image/jpeg" - case ".png", ".jpeg", ".gif", ".webp": - return "image/" + ext[1:] - case ".pdf": - return "application/pdf" - default: - return "text/plain" - } -} - -func (m *editorComponent) createAttachmentFromFile(filePath string) *attachment.Attachment { - ext := strings.ToLower(filepath.Ext(filePath)) - mediaType := getMediaTypeFromExtension(ext) - absolutePath := filePath - if !filepath.IsAbs(filePath) { - absolutePath = filepath.Join(util.CwdPath, filePath) - } - - // For text files, create a simple file reference - if mediaType == "text/plain" { - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - Display: "@" + filePath, - URL: fmt.Sprintf("file://%s", absolutePath), - Filename: filePath, - MediaType: mediaType, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - }, - } - } - - // For binary files (images, PDFs), read and encode - fileBytes, err := os.ReadFile(filePath) - if err != nil { - slog.Error("Failed to read file", "error", err) - return nil - } - - base64EncodedFile := base64.StdEncoding.EncodeToString(fileBytes) - url := fmt.Sprintf("data:%s;base64,%s", mediaType, base64EncodedFile) - attachmentCount := len(m.textarea.GetAttachments()) - attachmentIndex := attachmentCount + 1 - label := "File" - if strings.HasPrefix(mediaType, "image/") { - label = "Image" - } - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - MediaType: mediaType, - Display: fmt.Sprintf("[%s #%d]", label, attachmentIndex), - URL: url, - Filename: filePath, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - Data: fileBytes, - }, - } -} - -func (m *editorComponent) createAttachmentFromPath(filePath string) *attachment.Attachment { - extension := filepath.Ext(filePath) - mediaType := getMediaTypeFromExtension(extension) - absolutePath := filePath - if !filepath.IsAbs(filePath) { - absolutePath = filepath.Join(util.CwdPath, filePath) - } - return &attachment.Attachment{ - ID: uuid.NewString(), - Type: "file", - Display: "@" + filePath, - URL: fmt.Sprintf("file://%s", absolutePath), - Filename: filePath, - MediaType: mediaType, - Source: &attachment.FileSource{ - Path: absolutePath, - Mime: mediaType, - }, - } -} diff --git a/packages/tui/internal/components/chat/message.go b/packages/tui/internal/components/chat/message.go deleted file mode 100644 index 801545a88..000000000 --- a/packages/tui/internal/components/chat/message.go +++ /dev/null @@ -1,1031 +0,0 @@ -package chat - -import ( - "encoding/json" - "fmt" - "maps" - "slices" - "strings" - "time" - - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/lipgloss/v2/compat" - "github.com/charmbracelet/x/ansi" - "github.com/muesli/reflow/truncate" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/diff" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" - "golang.org/x/text/cases" - "golang.org/x/text/language" -) - -type blockRenderer struct { - textColor compat.AdaptiveColor - backgroundColor compat.AdaptiveColor - border bool - borderColor *compat.AdaptiveColor - borderLeft bool - borderRight bool - paddingTop int - paddingBottom int - paddingLeft int - paddingRight int - marginTop int - marginBottom int -} - -type renderingOption func(*blockRenderer) - -func WithTextColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.textColor = color - } -} - -func WithBackgroundColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.backgroundColor = color - } -} - -func WithNoBorder() renderingOption { - return func(c *blockRenderer) { - c.border = false - c.paddingLeft++ - c.paddingRight++ - } -} - -func WithBorderColor(color compat.AdaptiveColor) renderingOption { - return func(c *blockRenderer) { - c.borderColor = &color - } -} - -func WithBorderLeft() renderingOption { - return func(c *blockRenderer) { - c.borderLeft = true - c.borderRight = false - } -} - -func WithBorderRight() renderingOption { - return func(c *blockRenderer) { - c.borderLeft = false - c.borderRight = true - } -} - -func WithBorderBoth(value bool) renderingOption { - return func(c *blockRenderer) { - if value { - c.borderLeft = true - c.borderRight = true - } - } -} - -func WithMarginTop(padding int) renderingOption { - return func(c *blockRenderer) { - c.marginTop = padding - } -} - -func WithMarginBottom(padding int) renderingOption { - return func(c *blockRenderer) { - c.marginBottom = padding - } -} - -func WithPadding(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingTop = padding - c.paddingBottom = padding - c.paddingLeft = padding - c.paddingRight = padding - } -} - -func WithPaddingLeft(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingLeft = padding - } -} - -func WithPaddingRight(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingRight = padding - } -} - -func WithPaddingTop(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingTop = padding - } -} - -func WithPaddingBottom(padding int) renderingOption { - return func(c *blockRenderer) { - c.paddingBottom = padding - } -} - -func renderContentBlock( - app *app.App, - content string, - width int, - options ...renderingOption, -) string { - t := theme.CurrentTheme() - renderer := &blockRenderer{ - textColor: t.TextMuted(), - backgroundColor: t.BackgroundPanel(), - border: true, - borderLeft: true, - borderRight: false, - paddingTop: 1, - paddingBottom: 1, - paddingLeft: 2, - paddingRight: 2, - } - for _, option := range options { - option(renderer) - } - - borderColor := t.BackgroundPanel() - if renderer.borderColor != nil { - borderColor = *renderer.borderColor - } - - style := styles.NewStyle(). - Foreground(renderer.textColor). - Background(renderer.backgroundColor). - PaddingTop(renderer.paddingTop). - PaddingBottom(renderer.paddingBottom). - PaddingLeft(renderer.paddingLeft). - PaddingRight(renderer.paddingRight). - AlignHorizontal(lipgloss.Left) - - if renderer.border { - style = style. - BorderStyle(lipgloss.ThickBorder()). - BorderLeft(true). - BorderRight(true). - BorderLeftForeground(t.BackgroundPanel()). - BorderLeftBackground(t.Background()). - BorderRightForeground(t.BackgroundPanel()). - BorderRightBackground(t.Background()) - - if renderer.borderLeft { - style = style.BorderLeftForeground(borderColor) - } - if renderer.borderRight { - style = style.BorderRightForeground(borderColor) - } - } else { - style = style.PaddingLeft(renderer.paddingLeft).PaddingRight(renderer.paddingRight) - } - - content = style.Render(content) - if renderer.marginTop > 0 { - for range renderer.marginTop { - content = "\n" + content - } - } - if renderer.marginBottom > 0 { - for range renderer.marginBottom { - content = content + "\n" - } - } - - return content -} - -func renderText( - app *app.App, - message opencode.MessageUnion, - text string, - author string, - showToolDetails bool, - width int, - extra string, - isThinking bool, - isQueued bool, - shimmer bool, - fileParts []opencode.FilePart, - agentParts []opencode.AgentPart, - toolCalls ...opencode.ToolPart, -) string { - t := theme.CurrentTheme() - - var ts time.Time - backgroundColor := t.BackgroundPanel() - var content string - switch casted := message.(type) { - case opencode.AssistantMessage: - backgroundColor = t.Background() - if isThinking { - backgroundColor = t.BackgroundPanel() - } - ts = time.UnixMilli(int64(casted.Time.Created)) - if casted.Time.Completed > 0 { - ts = time.UnixMilli(int64(casted.Time.Completed)) - } - content = util.ToMarkdown(text, width, backgroundColor) - if isThinking { - var label string - if shimmer { - label = util.Shimmer("Thinking...", backgroundColor, t.TextMuted(), t.Accent()) - } else { - label = styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render("Thinking...") - } - label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) - content = label + "\n\n" + content - } else if strings.TrimSpace(text) == "Generating..." { - label := util.Shimmer(text, backgroundColor, t.TextMuted(), t.Text()) - label = styles.NewStyle().Background(backgroundColor).Width(width - 6).Render(label) - content = label - } - case opencode.UserMessage: - ts = time.UnixMilli(int64(casted.Time.Created)) - base := styles.NewStyle().Foreground(t.Text()).Background(backgroundColor) - - var result strings.Builder - lastEnd := int64(0) - - // Apply highlighting to filenames and base style to rest of text BEFORE wrapping - textLen := int64(len(text)) - - // Collect all parts to highlight (both file and agent parts) - type highlightPart struct { - start int64 - end int64 - color compat.AdaptiveColor - } - var highlights []highlightPart - - // Add file parts with secondary color - for _, filePart := range fileParts { - highlights = append(highlights, highlightPart{ - start: filePart.Source.Text.Start, - end: filePart.Source.Text.End, - color: t.Secondary(), - }) - } - - // Add agent parts with secondary color (same as file parts) - for _, agentPart := range agentParts { - highlights = append(highlights, highlightPart{ - start: agentPart.Source.Start, - end: agentPart.Source.End, - color: t.Secondary(), - }) - } - - // Sort highlights by start position - slices.SortFunc(highlights, func(a, b highlightPart) int { - if a.start < b.start { - return -1 - } - if a.start > b.start { - return 1 - } - return 0 - }) - - // Merge overlapping highlights to prevent duplication - merged := make([]highlightPart, 0) - for _, part := range highlights { - if len(merged) == 0 { - merged = append(merged, part) - continue - } - - last := &merged[len(merged)-1] - // If current part overlaps with the last one, merge them - if part.start <= last.end { - if part.end > last.end { - last.end = part.end - } - } else { - merged = append(merged, part) - } - } - - for _, part := range merged { - highlight := base.Foreground(part.color) - start, end := part.start, part.end - - if end > textLen { - end = textLen - } - if start > textLen { - start = textLen - } - - if start > lastEnd { - result.WriteString(base.Render(text[lastEnd:start])) - } - if start < end { - result.WriteString(highlight.Render(text[start:end])) - } - - lastEnd = end - } - - if lastEnd < textLen { - result.WriteString(base.Render(text[lastEnd:])) - } - - // wrap styled text - styledText := result.String() - styledText = strings.ReplaceAll(styledText, "-", "\u2011") - wrappedText := ansi.WordwrapWc(styledText, width-6, " ") - wrappedText = strings.ReplaceAll(wrappedText, "\u2011", "-") - content = base.Width(width - 6).Render(wrappedText) - if isQueued { - queuedStyle := styles.NewStyle().Background(t.Accent()).Foreground(t.BackgroundPanel()).Bold(true).Padding(0, 1) - content = queuedStyle.Render("QUEUED") + "\n\n" + content - } - } - - timestamp := ts. - Local(). - Format("02 Jan 2006 03:04 PM") - if time.Now().Format("02 Jan 2006") == timestamp[:11] { - timestamp = timestamp[12:] - } - timestamp = styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Render(" (" + timestamp + ")") - - // Check if this is an assistant message with agent information - var modelAndAgentSuffix string - if assistantMsg, ok := message.(opencode.AssistantMessage); ok && assistantMsg.Mode != "" { - // Find the agent index by name to get the correct color - var agentIndex int - for i, agent := range app.Agents { - if agent.Name == assistantMsg.Mode { - agentIndex = i - break - } - } - - // Get agent color based on the original agent index (same as status bar) - agentColor := util.GetAgentColor(agentIndex) - - // Style the agent name with the same color as status bar - agentName := cases.Title(language.Und).String(assistantMsg.Mode) - styledAgentName := styles.NewStyle(). - Background(backgroundColor). - Foreground(agentColor). - Render(agentName + " ") - styledModelID := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Render(assistantMsg.ModelID) - modelAndAgentSuffix = styledAgentName + styledModelID - } - - var info string - if modelAndAgentSuffix != "" { - info = modelAndAgentSuffix + timestamp - } else { - info = author + timestamp - } - if !showToolDetails && toolCalls != nil && len(toolCalls) > 0 { - for _, toolCall := range toolCalls { - title := renderToolTitle(toolCall, width-2) - style := styles.NewStyle() - if toolCall.State.Status == opencode.ToolPartStateStatusError { - style = style.Foreground(t.Error()) - } - title = style.Render(title) - title = "\n∟ " + title - content = content + title - } - } - - sections := []string{content} - if extra != "" { - sections = append(sections, "\n"+extra+"\n") - } - sections = append(sections, info) - content = strings.Join(sections, "\n") - - switch message.(type) { - case opencode.UserMessage: - borderColor := t.Secondary() - if isQueued { - borderColor = t.Accent() - } - return renderContentBlock( - app, - content, - width, - WithTextColor(t.Text()), - WithBorderColor(borderColor), - ) - case opencode.AssistantMessage: - if isThinking { - return renderContentBlock( - app, - content, - width, - WithTextColor(t.Text()), - WithBackgroundColor(t.BackgroundPanel()), - WithBorderColor(t.BackgroundPanel()), - ) - } - return renderContentBlock( - app, - content, - width, - WithNoBorder(), - WithBackgroundColor(t.Background()), - ) - } - return "" -} - -func renderToolDetails( - app *app.App, - toolCall opencode.ToolPart, - permission opencode.Permission, - width int, -) string { - measure := util.Measure("chat.renderToolDetails") - defer measure("tool", toolCall.Tool) - ignoredTools := []string{"todoread"} - if slices.Contains(ignoredTools, toolCall.Tool) { - return "" - } - - if toolCall.State.Status == opencode.ToolPartStateStatusPending { - title := renderToolTitle(toolCall, width) - return renderContentBlock(app, title, width) - } - - var result *string - if toolCall.State.Output != "" { - result = &toolCall.State.Output - } - - toolInputMap := make(map[string]any) - if toolCall.State.Input != nil { - value := toolCall.State.Input - if m, ok := value.(map[string]any); ok { - toolInputMap = m - keys := make([]string, 0, len(toolInputMap)) - for key := range toolInputMap { - keys = append(keys, key) - } - slices.Sort(keys) - } - } - - body := "" - t := theme.CurrentTheme() - backgroundColor := t.BackgroundPanel() - borderColor := t.BackgroundPanel() - defaultStyle := styles.NewStyle().Background(backgroundColor).Width(width - 6).Render - baseStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.Text()).Render - mutedStyle := styles.NewStyle().Background(backgroundColor).Foreground(t.TextMuted()).Render - - permissionContent := "" - if permission.ID != "" { - borderColor = t.Warning() - - base := styles.NewStyle().Background(backgroundColor) - text := base.Foreground(t.Text()).Bold(true).Render - muted := base.Foreground(t.TextMuted()).Render - if permission.Type == "doom-loop" { - permissionContent = permission.Title + "\n\n" - } else { - permissionContent = "Permission required to run this tool:\n\n" - } - permissionContent += text( - "enter ", - ) + muted( - "accept ", - ) + text( - "a", - ) + muted( - " accept always ", - ) + text( - "esc", - ) + muted( - " reject", - ) - - } - - if permission.Metadata != nil { - metadata, ok := toolCall.State.Metadata.(map[string]any) - if metadata == nil || !ok { - metadata = map[string]any{} - } - maps.Copy(metadata, permission.Metadata) - toolCall.State.Metadata = metadata - } - - if toolCall.State.Metadata != nil { - metadata := toolCall.State.Metadata.(map[string]any) - switch toolCall.Tool { - case "read": - var preview any - if metadata != nil { - preview = metadata["preview"] - } - if preview != nil && toolInputMap["filePath"] != nil { - filename := toolInputMap["filePath"].(string) - body = preview.(string) - body = util.RenderFile(filename, body, width, util.WithTruncate(6)) - } - case "edit": - if filename, ok := toolInputMap["filePath"].(string); ok { - var diffField any - if metadata != nil { - diffField = metadata["diff"] - } - if diffField != nil { - patch := diffField.(string) - var formattedDiff string - if width < 120 { - formattedDiff, _ = diff.FormatUnifiedDiff( - filename, - patch, - diff.WithWidth(width-2), - ) - } else { - formattedDiff, _ = diff.FormatDiff( - filename, - patch, - diff.WithWidth(width-2), - ) - } - body = strings.TrimSpace(formattedDiff) - style := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.TextMuted()). - Padding(1, 2). - Width(width - 4) - - if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-6); diagnostics != "" { - diagnostics = style.Render(diagnostics) - body += "\n" + diagnostics - } - - title := renderToolTitle(toolCall, width) - title = style.Render(title) - content := title + "\n" + body - - if toolCall.State.Status == opencode.ToolPartStateStatusError { - errorStyle := styles.NewStyle(). - Background(backgroundColor). - Foreground(t.Error()). - Padding(1, 2). - Width(width - 4) - errorContent := errorStyle.Render(toolCall.State.Error) - content += "\n" + errorContent - } - - if permissionContent != "" { - permissionContent = styles.NewStyle(). - Background(backgroundColor). - Padding(1, 2). - Render(permissionContent) - content += "\n" + permissionContent - } - content = renderContentBlock( - app, - content, - width, - WithPadding(0), - WithBorderColor(borderColor), - WithBorderBoth(permission.ID != ""), - ) - return content - } - } - case "write": - if filename, ok := toolInputMap["filePath"].(string); ok { - if content, ok := toolInputMap["content"].(string); ok { - body = util.RenderFile(filename, content, width) - if diagnostics := renderDiagnostics(metadata, filename, backgroundColor, width-4); diagnostics != "" { - body += "\n\n" + diagnostics - } - } - } - case "bash": - if command, ok := toolInputMap["command"].(string); ok { - body = fmt.Sprintf("```console\n$ %s\n", command) - output := metadata["output"] - if output != nil { - body += ansi.Strip(fmt.Sprintf("%s", output)) - } - body += "```" - body = util.ToMarkdown(body, width, backgroundColor) - } - case "webfetch": - if format, ok := toolInputMap["format"].(string); ok && result != nil { - body = *result - body = util.TruncateHeight(body, 10) - if format == "html" || format == "markdown" { - body = util.ToMarkdown(body, width, backgroundColor) - } - } - case "todowrite": - todos := metadata["todos"] - if todos != nil { - for _, item := range todos.([]any) { - todo := item.(map[string]any) - content := todo["content"] - if content == nil { - continue - } - switch todo["status"] { - case "completed": - body += fmt.Sprintf("- [x] %s\n", content) - case "cancelled": - // strike through cancelled todo - body += fmt.Sprintf("- [ ] ~~%s~~\n", content) - case "in_progress": - // highlight in progress todo - body += fmt.Sprintf("- [ ] `%s`\n", content) - default: - body += fmt.Sprintf("- [ ] %s\n", content) - } - } - body = util.ToMarkdown(body, width, backgroundColor) - } - case "task": - summary := metadata["summary"] - if summary != nil { - toolcalls := summary.([]any) - steps := []string{} - for _, item := range toolcalls { - data, _ := json.Marshal(item) - var toolCall opencode.ToolPart - _ = json.Unmarshal(data, &toolCall) - step := renderToolTitle(toolCall, width-2) - step = "∟ " + step - steps = append(steps, step) - } - body = strings.Join(steps, "\n") - - body += "\n\n" - - // Build navigation hint with proper spacing - cycleKeybind := app.Keybind(commands.SessionChildCycleCommand) - cycleReverseKeybind := app.Keybind(commands.SessionChildCycleReverseCommand) - - var navParts []string - if cycleKeybind != "" { - navParts = append(navParts, baseStyle(cycleKeybind)) - } - if cycleReverseKeybind != "" { - navParts = append(navParts, baseStyle(cycleReverseKeybind)) - } - - if len(navParts) > 0 { - body += strings.Join(navParts, mutedStyle(", ")) + mutedStyle(" navigate child sessions") - } - } - body = defaultStyle(body) - default: - if result == nil { - empty := "" - result = &empty - } - body = *result - body = util.TruncateHeight(body, 10) - body = defaultStyle(body) - } - } - - error := "" - if toolCall.State.Status == opencode.ToolPartStateStatusError { - error = toolCall.State.Error - } - - if error != "" { - errorContent := styles.NewStyle(). - Width(width - 6). - Foreground(t.Error()). - Background(backgroundColor). - Render(error) - - if body == "" { - body = errorContent - } else { - body += "\n\n" + errorContent - } - } - - if body == "" && error == "" && result != nil { - body = *result - body = util.TruncateHeight(body, 10) - body = defaultStyle(body) - } - - if body == "" { - body = defaultStyle("") - } - - title := renderToolTitle(toolCall, width) - content := title + "\n\n" + body - - if permissionContent != "" { - content += "\n\n\n" + permissionContent - } - - return renderContentBlock( - app, - content, - width, - WithBorderColor(borderColor), - WithBorderBoth(permission.ID != ""), - ) -} - -func renderToolName(name string) string { - switch name { - case "bash": - return "Shell" - case "webfetch": - return "Fetch" - case "invalid": - return "Invalid" - default: - normalizedName := name - if after, ok := strings.CutPrefix(name, "opencode_"); ok { - normalizedName = after - } - return cases.Title(language.Und).String(normalizedName) - } -} - -func getTodoPhase(metadata map[string]any) string { - todos, ok := metadata["todos"].([]any) - if !ok || len(todos) == 0 { - return "Plan" - } - - counts := map[string]int{"pending": 0, "completed": 0} - for _, item := range todos { - if todo, ok := item.(map[string]any); ok { - if status, ok := todo["status"].(string); ok { - counts[status]++ - } - } - } - - total := len(todos) - switch { - case counts["pending"] == total: - return "Creating plan" - case counts["completed"] == total: - return "Completing plan" - default: - return "Updating plan" - } -} - -func getTodoTitle(toolCall opencode.ToolPart) string { - if toolCall.State.Status == opencode.ToolPartStateStatusCompleted { - if metadata, ok := toolCall.State.Metadata.(map[string]any); ok { - return getTodoPhase(metadata) - } - } - return "Plan" -} - -func renderToolTitle( - toolCall opencode.ToolPart, - width int, -) string { - if toolCall.State.Status == opencode.ToolPartStateStatusPending { - title := renderToolAction(toolCall.Tool) - t := theme.CurrentTheme() - shiny := util.Shimmer(title, t.BackgroundPanel(), t.TextMuted(), t.Accent()) - return styles.NewStyle().Background(t.BackgroundPanel()).Width(width - 6).Render(shiny) - } - - toolArgs := "" - toolArgsMap := make(map[string]any) - if toolCall.State.Input != nil { - value := toolCall.State.Input - if m, ok := value.(map[string]any); ok { - toolArgsMap = m - - keys := make([]string, 0, len(toolArgsMap)) - for key := range toolArgsMap { - keys = append(keys, key) - } - slices.Sort(keys) - firstKey := "" - if len(keys) > 0 { - firstKey = keys[0] - } - - toolArgs = renderArgs(&toolArgsMap, firstKey) - } - } - - title := renderToolName(toolCall.Tool) - switch toolCall.Tool { - case "read": - toolArgs = renderArgs(&toolArgsMap, "filePath") - title = fmt.Sprintf("%s %s", title, toolArgs) - case "edit", "write": - if filename, ok := toolArgsMap["filePath"].(string); ok { - title = fmt.Sprintf("%s %s", title, util.Relative(filename)) - } - case "bash": - if description, ok := toolArgsMap["description"].(string); ok { - title = fmt.Sprintf("%s %s", title, description) - } - case "task": - description := toolArgsMap["description"] - subagent := toolArgsMap["subagent_type"] - if description != nil && subagent != nil { - title = fmt.Sprintf("%s[%s] %s", title, subagent, description) - } else if description != nil { - title = fmt.Sprintf("%s %s", title, description) - } - case "webfetch": - toolArgs = renderArgs(&toolArgsMap, "url") - title = fmt.Sprintf("%s %s", title, toolArgs) - case "todowrite": - title = getTodoTitle(toolCall) - case "todoread": - return "Plan" - case "invalid": - if actualTool, ok := toolArgsMap["tool"].(string); ok { - title = renderToolName(actualTool) - } - default: - toolName := renderToolName(toolCall.Tool) - title = fmt.Sprintf("%s %s", toolName, toolArgs) - } - - title = truncate.StringWithTail(title, uint(width-6), "...") - if toolCall.State.Error != "" { - t := theme.CurrentTheme() - title = styles.NewStyle().Foreground(t.Error()).Render(title) - } - return title -} - -func renderToolAction(name string) string { - switch name { - case "task": - return "Delegating..." - case "bash": - return "Writing command..." - case "edit": - return "Preparing edit..." - case "webfetch": - return "Fetching from the web..." - case "glob": - return "Finding files..." - case "grep": - return "Searching content..." - case "list": - return "Listing directory..." - case "read": - return "Reading file..." - case "write": - return "Preparing write..." - case "todowrite", "todoread": - return "Planning..." - case "patch": - return "Preparing patch..." - } - return "Working..." -} - -func renderArgs(args *map[string]any, titleKey string) string { - if args == nil || len(*args) == 0 { - return "" - } - - keys := make([]string, 0, len(*args)) - for key := range *args { - keys = append(keys, key) - } - slices.Sort(keys) - - title := "" - parts := []string{} - for _, key := range keys { - value := (*args)[key] - if value == nil { - continue - } - if key == "filePath" || key == "path" { - if strValue, ok := value.(string); ok { - value = util.Relative(strValue) - } - } - if key == titleKey { - title = fmt.Sprintf("%s", value) - continue - } - parts = append(parts, fmt.Sprintf("%s=%v", key, value)) - } - if len(parts) == 0 { - return title - } - return fmt.Sprintf("%s (%s)", title, strings.Join(parts, ", ")) -} - -// Diagnostic represents an LSP diagnostic -type Diagnostic struct { - Range struct { - Start struct { - Line int `json:"line"` - Character int `json:"character"` - } `json:"start"` - } `json:"range"` - Severity int `json:"severity"` - Message string `json:"message"` -} - -// renderDiagnostics formats LSP diagnostics for display in the TUI -func renderDiagnostics( - metadata map[string]any, - filePath string, - backgroundColor compat.AdaptiveColor, - width int, -) string { - if diagnosticsData, ok := metadata["diagnostics"].(map[string]any); ok { - if fileDiagnostics, ok := diagnosticsData[filePath].([]any); ok { - var errorDiagnostics []string - for _, diagInterface := range fileDiagnostics { - diagMap, ok := diagInterface.(map[string]any) - if !ok { - continue - } - // Parse the diagnostic - var diag Diagnostic - diagBytes, err := json.Marshal(diagMap) - if err != nil { - continue - } - if err := json.Unmarshal(diagBytes, &diag); err != nil { - continue - } - // Only show error diagnostics (severity === 1) - if diag.Severity != 1 { - continue - } - line := diag.Range.Start.Line + 1 // 1-based - column := diag.Range.Start.Character + 1 // 1-based - errorDiagnostics = append( - errorDiagnostics, - fmt.Sprintf("Error [%d:%d] %s", line, column, diag.Message), - ) - } - if len(errorDiagnostics) == 0 { - return "" - } - t := theme.CurrentTheme() - var result strings.Builder - for _, diagnostic := range errorDiagnostics { - if result.Len() > 0 { - result.WriteString("\n\n") - } - diagnostic = ansi.WordwrapWc(diagnostic, width, " -") - result.WriteString( - styles.NewStyle(). - Background(backgroundColor). - Foreground(t.Error()). - Render(diagnostic), - ) - } - return result.String() - } - } - return "" - - // diagnosticsData should be a map[string][]Diagnostic - // strDiagnosticsData := diagnosticsData.Raw() - // diagnosticsMap := gjson.Parse(strDiagnosticsData).Value().(map[string]any) - // fileDiagnostics, ok := diagnosticsMap[filePath] - // if !ok { - // return "" - // } - - // diagnosticsList, ok := fileDiagnostics.([]any) - // if !ok { - // return "" - // } - -} diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go deleted file mode 100644 index 3d52b84e5..000000000 --- a/packages/tui/internal/components/chat/messages.go +++ /dev/null @@ -1,1322 +0,0 @@ -package chat - -import ( - "context" - "fmt" - "log/slog" - "slices" - "sort" - "strconv" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/charmbracelet/x/ansi" - "github.com/sst/opencode-sdk-go" - "github.com/sst/opencode/internal/app" - "github.com/sst/opencode/internal/commands" - "github.com/sst/opencode/internal/components/dialog" - "github.com/sst/opencode/internal/components/diff" - "github.com/sst/opencode/internal/components/toast" - "github.com/sst/opencode/internal/layout" - "github.com/sst/opencode/internal/styles" - "github.com/sst/opencode/internal/theme" - "github.com/sst/opencode/internal/util" - "github.com/sst/opencode/internal/viewport" -) - -type MessagesComponent interface { - tea.Model - tea.ViewModel - PageUp() (tea.Model, tea.Cmd) - PageDown() (tea.Model, tea.Cmd) - HalfPageUp() (tea.Model, tea.Cmd) - HalfPageDown() (tea.Model, tea.Cmd) - ToolDetailsVisible() bool - ThinkingBlocksVisible() bool - GotoTop() (tea.Model, tea.Cmd) - GotoBottom() (tea.Model, tea.Cmd) - CopyLastMessage() (tea.Model, tea.Cmd) - UndoLastMessage() (tea.Model, tea.Cmd) - RedoLastMessage() (tea.Model, tea.Cmd) - ScrollToMessage(messageID string) (tea.Model, tea.Cmd) -} - -type messagesComponent struct { - width, height int - app *app.App - header string - viewport viewport.Model - clipboard []string - cache *PartCache - loading bool - showToolDetails bool - showThinkingBlocks bool - rendering bool - dirty bool - tail bool - partCount int - lineCount int - selection *selection - messagePositions map[string]int // map message ID to line position - animating bool -} - -type selection struct { - startX int - endX int - startY int - endY int -} - -func (s selection) coords(offset int) *selection { - // selecting backwards - if s.startY > s.endY && s.endY >= 0 { - return &selection{ - startX: max(0, s.endX-1), - startY: s.endY - offset, - endX: s.startX + 1, - endY: s.startY - offset, - } - } - - // selecting backwards same line - if s.startY == s.endY && s.startX >= s.endX { - return &selection{ - startY: s.startY - offset, - startX: max(0, s.endX-1), - endY: s.endY - offset, - endX: s.startX + 1, - } - } - - return &selection{ - startX: s.startX, - startY: s.startY - offset, - endX: s.endX, - endY: s.endY - offset, - } -} - -type ToggleToolDetailsMsg struct{} -type ToggleThinkingBlocksMsg struct{} -type shimmerTickMsg struct{} - -func (m *messagesComponent) Init() tea.Cmd { - return tea.Batch(m.viewport.Init()) -} - -func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - var cmds []tea.Cmd - switch msg := msg.(type) { - case shimmerTickMsg: - if !m.app.HasAnimatingWork() { - m.animating = false - return m, nil - } - return m, tea.Sequence( - m.renderView(), - tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }), - ) - case tea.MouseClickMsg: - slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset) - y := msg.Y + m.viewport.YOffset - if y > 0 { - m.selection = &selection{ - startY: y, - startX: msg.X, - endY: -1, - endX: -1, - } - - slog.Info("mouse selection", "start", fmt.Sprintf("%d,%d", m.selection.startX, m.selection.startY), "end", fmt.Sprintf("%d,%d", m.selection.endX, m.selection.endY)) - return m, m.renderView() - } - - case tea.MouseMotionMsg: - if m.selection != nil { - m.selection = &selection{ - startX: m.selection.startX, - startY: m.selection.startY, - endX: msg.X + 1, - endY: msg.Y + m.viewport.YOffset, - } - return m, m.renderView() - } - - case tea.MouseReleaseMsg: - if m.selection != nil { - m.selection = nil - if len(m.clipboard) > 0 { - content := strings.Join(m.clipboard, "\n") - m.clipboard = []string{} - return m, tea.Sequence( - m.renderView(), - app.SetClipboard(content), - toast.NewSuccessToast("Copied to clipboard"), - ) - } - return m, m.renderView() - } - case tea.WindowSizeMsg: - effectiveWidth := msg.Width - 4 - // Clear cache on resize since width affects rendering - if m.width != effectiveWidth { - m.cache.Clear() - } - m.width = effectiveWidth - m.height = msg.Height - 7 - m.viewport.SetWidth(m.width) - m.loading = true - return m, m.renderView() - case app.SendPrompt: - m.viewport.GotoBottom() - m.tail = true - return m, nil - case app.SendCommand: - m.viewport.GotoBottom() - m.tail = true - return m, nil - case dialog.ThemeSelectedMsg: - m.cache.Clear() - m.loading = true - return m, m.renderView() - case ToggleToolDetailsMsg: - m.showToolDetails = !m.showToolDetails - m.app.State.ShowToolDetails = &m.showToolDetails - return m, tea.Batch(m.renderView(), m.app.SaveState()) - case ToggleThinkingBlocksMsg: - m.showThinkingBlocks = !m.showThinkingBlocks - m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks - return m, tea.Batch(m.renderView(), m.app.SaveState()) - case app.SessionLoadedMsg: - m.tail = true - m.loading = true - return m, m.renderView() - case app.SessionClearedMsg: - m.cache.Clear() - m.tail = true - m.loading = true - return m, m.renderView() - case app.SessionUnrevertedMsg: - if msg.Session.ID == m.app.Session.ID { - m.cache.Clear() - m.tail = true - return m, m.renderView() - } - case app.SessionSelectedMsg: - currentParent := m.app.Session.ParentID - if currentParent == "" { - currentParent = m.app.Session.ID - } - - targetParent := msg.ParentID - if targetParent == "" { - targetParent = msg.ID - } - - // Clear cache only if switching between different session families - if currentParent != targetParent { - m.cache.Clear() - } - - m.viewport.GotoBottom() - case app.MessageRevertedMsg: - if msg.Session.ID == m.app.Session.ID { - m.cache.Clear() - m.tail = true - return m, m.renderView() - } - - case opencode.EventListResponseEventSessionUpdated: - if msg.Properties.Info.ID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessageUpdated: - if msg.Properties.Info.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventSessionError: - if msg.Properties.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessagePartUpdated: - if msg.Properties.Part.SessionID == m.app.Session.ID { - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessageRemoved: - if msg.Properties.SessionID == m.app.Session.ID { - m.cache.Clear() - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventMessagePartRemoved: - if msg.Properties.SessionID == m.app.Session.ID { - // Clear the cache when a part is removed to ensure proper re-rendering - m.cache.Clear() - cmds = append(cmds, m.renderView()) - } - case opencode.EventListResponseEventPermissionUpdated: - m.tail = true - return m, m.renderView() - case opencode.EventListResponseEventPermissionReplied: - m.tail = true - return m, m.renderView() - case renderCompleteMsg: - m.partCount = msg.partCount - m.lineCount = msg.lineCount - m.rendering = false - m.clipboard = msg.clipboard - m.loading = false - m.messagePositions = msg.messagePositions - m.tail = m.viewport.AtBottom() - - // Preserve scroll across reflow - // if the user was at bottom, keep following; otherwise restore the previous offset. - wasAtBottom := m.viewport.AtBottom() - prevYOffset := m.viewport.YOffset - m.viewport = msg.viewport - if wasAtBottom { - m.viewport.GotoBottom() - } else { - m.viewport.YOffset = prevYOffset - } - - m.header = msg.header - if m.dirty { - cmds = append(cmds, m.renderView()) - } - - // Start shimmer ticks if any assistant/tool is in-flight - if !m.animating && m.app.HasAnimatingWork() { - m.animating = true - cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} })) - } - } - - m.tail = m.viewport.AtBottom() - viewport, cmd := m.viewport.Update(msg) - m.viewport = viewport - cmds = append(cmds, cmd) - - return m, tea.Batch(cmds...) -} - -type renderCompleteMsg struct { - viewport viewport.Model - clipboard []string - header string - partCount int - lineCount int - messagePositions map[string]int -} - -func (m *messagesComponent) renderView() tea.Cmd { - if m.rendering { - slog.Debug("pending render, skipping") - m.dirty = true - return func() tea.Msg { - return nil - } - } - m.dirty = false - m.rendering = true - - viewport := m.viewport - tail := m.tail - - return func() tea.Msg { - header := m.renderHeader() - measure := util.Measure("messages.renderView") - defer measure() - - t := theme.CurrentTheme() - blocks := make([]string, 0) - partCount := 0 - lineCount := 0 - messagePositions := make(map[string]int) // Track message ID to line position - - orphanedToolCalls := make([]opencode.ToolPart, 0) - - width := m.width // always use full width - - // Find the last streaming ReasoningPart to only shimmer that one - lastStreamingReasoningID := "" - if m.showThinkingBlocks { - for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- { - if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok { - continue - } - parts := m.app.Messages[mi].Parts - for pi := len(parts) - 1; pi >= 0; pi-- { - if rp, ok := parts[pi].(opencode.ReasoningPart); ok { - if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 { - lastStreamingReasoningID = rp.ID - break - } - } - } - } - } - - reverted := false - revertedMessageCount := 0 - revertedToolCount := 0 - lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" - for _, msg := range slices.Backward(m.app.Messages) { - if assistant, ok := msg.Info.(opencode.AssistantMessage); ok { - if assistant.Time.Completed > 0 { - break - } - lastAssistantMessage = assistant.ID - break - } - } - for _, message := range m.app.Messages { - var content string - var cached bool - error := "" - - switch casted := message.Info.(type) { - case opencode.UserMessage: - // Track the position of this user message - messagePositions[casted.ID] = lineCount - - if casted.ID == m.app.Session.Revert.MessageID { - reverted = true - revertedMessageCount = 1 - revertedToolCount = 0 - continue - } - if reverted { - revertedMessageCount++ - continue - } - - for partIndex, part := range message.Parts { - switch part := part.(type) { - case opencode.TextPart: - if part.Synthetic { - continue - } - if part.Text == "" { - continue - } - remainingParts := message.Parts[partIndex+1:] - fileParts := make([]opencode.FilePart, 0) - agentParts := make([]opencode.AgentPart, 0) - for _, part := range remainingParts { - switch part := part.(type) { - case opencode.FilePart: - if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start { - fileParts = append(fileParts, part) - } - case opencode.AgentPart: - if part.Source.Start >= 0 && part.Source.End >= part.Source.Start { - agentParts = append(agentParts, part) - } - } - } - flexItems := []layout.FlexItem{} - if len(fileParts) > 0 { - fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1) - mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1) - for _, filePart := range fileParts { - mediaType := "" - switch filePart.Mime { - case "text/plain": - mediaType = "txt" - case "image/png", "image/jpeg", "image/gif", "image/webp": - mediaType = "img" - mediaTypeStyle = mediaTypeStyle.Background(t.Accent()) - case "application/pdf": - mediaType = "pdf" - mediaTypeStyle = mediaTypeStyle.Background(t.Primary()) - } - flexItems = append(flexItems, layout.FlexItem{ - View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename), - }) - } - } - bgColor := t.BackgroundPanel() - files := layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Width: width - 6, - Direction: layout.Column, - }, - flexItems..., - ) - - author := m.app.Config.Username - isQueued := casted.ID > lastAssistantMessage - key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued) - content, cached = m.cache.Get(key) - if !cached { - content = renderText( - m.app, - message.Info, - part.Text, - author, - m.showToolDetails, - width, - files, - false, - isQueued, - false, - fileParts, - agentParts, - ) - m.cache.Set(key, content) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - } - - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - reverted = true - revertedMessageCount = 1 - revertedToolCount = 0 - } - hasTextPart := false - hasContent := false - for partIndex, p := range message.Parts { - switch part := p.(type) { - case opencode.TextPart: - if reverted { - continue - } - if strings.TrimSpace(part.Text) == "" { - continue - } - hasTextPart = true - finished := part.Time.End > 0 - remainingParts := message.Parts[partIndex+1:] - toolCallParts := make([]opencode.ToolPart, 0) - - // sometimes tool calls happen without an assistant message - // these should be included in this assistant message as well - if len(orphanedToolCalls) > 0 { - toolCallParts = append(toolCallParts, orphanedToolCalls...) - orphanedToolCalls = make([]opencode.ToolPart, 0) - } - - remaining := true - for _, part := range remainingParts { - if !remaining { - break - } - switch part := part.(type) { - case opencode.TextPart: - // we only want tool calls associated with the current text part. - // if we hit another text part, we're done. - remaining = false - case opencode.ToolPart: - toolCallParts = append(toolCallParts, part) - if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError { - // i don't think there's a case where a tool call isn't in result state - // and the message time is 0, but just in case - finished = false - } - } - } - - if finished { - key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts) - content, cached = m.cache.Get(key) - if !cached { - content = renderText( - m.app, - message.Info, - part.Text, - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - toolCallParts..., - ) - m.cache.Set(key, content) - } - } else { - content = renderText( - m.app, - message.Info, - part.Text, - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - toolCallParts..., - ) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - case opencode.ToolPart: - if reverted { - revertedToolCount++ - continue - } - - permission := opencode.Permission{} - if m.app.CurrentPermission.CallID == part.CallID { - permission = m.app.CurrentPermission - } - - if !m.showToolDetails && permission.ID == "" { - if !hasTextPart { - orphanedToolCalls = append(orphanedToolCalls, part) - } - continue - } - - if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError { - key := m.cache.GenerateKey(casted.ID, - part.ID, - m.showToolDetails, - width, - permission.ID, - ) - content, cached = m.cache.Get(key) - if !cached { - content = renderToolDetails( - m.app, - part, - permission, - width, - ) - m.cache.Set(key, content) - } - } else { - // if the tool call isn't finished, don't cache - content = renderToolDetails( - m.app, - part, - permission, - width, - ) - } - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - case opencode.ReasoningPart: - if reverted { - continue - } - if !m.showThinkingBlocks { - continue - } - if part.Text != "" { - text := part.Text - shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID - content = renderText( - m.app, - message.Info, - text, - casted.ModelID, - m.showToolDetails, - width, - "", - true, - false, - shimmer, - []opencode.FilePart{}, - []opencode.AgentPart{}, - ) - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - hasContent = true - } - } - } - - switch err := casted.Error.AsUnion().(type) { - case nil: - case opencode.AssistantMessageErrorMessageOutputLengthError: - error = "Message output length exceeded" - case opencode.AssistantMessageErrorAPIError: - error = err.Data.Message - case opencode.ProviderAuthError: - error = err.Data.Message - case opencode.MessageAbortedError: - error = "Request was aborted" - case opencode.UnknownError: - error = err.Data.Message - } - - if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 { - content = renderText( - m.app, - message.Info, - "Generating...", - casted.ModelID, - m.showToolDetails, - width, - "", - false, - false, - false, - []opencode.FilePart{}, - []opencode.AgentPart{}, - ) - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - - if error != "" && !reverted { - error = styles.NewStyle().Width(width - 6).Render(error) - error = renderContentBlock( - m.app, - error, - width, - WithBorderColor(t.Error()), - ) - blocks = append(blocks, error) - lineCount += lipgloss.Height(error) + 1 - } - } - - if revertedMessageCount > 0 || revertedToolCount > 0 { - messagePlural := "" - toolPlural := "" - if revertedMessageCount != 1 { - messagePlural = "s" - } - if revertedToolCount != 1 { - toolPlural = "s" - } - revertedStyle := styles.NewStyle(). - Background(t.BackgroundPanel()). - Foreground(t.TextMuted()) - - content := revertedStyle.Render(fmt.Sprintf( - "%d message%s reverted, %d tool call%s reverted", - revertedMessageCount, - messagePlural, - revertedToolCount, - toolPlural, - )) - hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text()) - hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand)) - hint += revertedStyle.Render(" (or /redo) to restore") - - content += "\n" + hint - if m.app.Session.Revert.Diff != "" { - t := theme.CurrentTheme() - s := styles.NewStyle().Background(t.BackgroundPanel()) - green := s.Foreground(t.Success()).Render - red := s.Foreground(t.Error()).Render - content += "\n" - stats, err := diff.ParseStats(m.app.Session.Revert.Diff) - if err != nil { - slog.Error("Failed to parse diff stats", "error", err) - } else { - var files []string - for file := range stats { - files = append(files, file) - } - sort.Strings(files) - - for _, file := range files { - fileStats := stats[file] - display := file - if fileStats.Added > 0 { - display += green(" +" + strconv.Itoa(int(fileStats.Added))) - } - if fileStats.Removed > 0 { - display += red(" -" + strconv.Itoa(int(fileStats.Removed))) - } - content += "\n" + display - } - } - } - - content = styles.NewStyle(). - Background(t.BackgroundPanel()). - Width(width - 6). - Render(content) - content = renderContentBlock( - m.app, - content, - width, - WithBorderColor(t.BackgroundPanel()), - ) - blocks = append(blocks, content) - } - - if m.app.CurrentPermission.ID != "" && - m.app.CurrentPermission.SessionID != m.app.Session.ID { - response, err := m.app.Client.Session.Message( - context.Background(), - m.app.CurrentPermission.SessionID, - m.app.CurrentPermission.MessageID, - opencode.SessionMessageParams{}, - ) - if err != nil || response == nil { - slog.Error("Failed to get message from child session", "error", err) - } else { - for _, part := range response.Parts { - if part.CallID == m.app.CurrentPermission.CallID { - if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok { - content := renderToolDetails( - m.app, - toolPart, - m.app.CurrentPermission, - width, - ) - if content != "" { - partCount++ - lineCount += lipgloss.Height(content) + 1 - blocks = append(blocks, content) - } - } - } - } - } - } - - final := []string{} - clipboard := []string{} - var selection *selection - if m.selection != nil { - selection = m.selection.coords(lipgloss.Height(header) + 1) - } - for _, block := range blocks { - lines := strings.Split(block, "\n") - for index, line := range lines { - if selection == nil || index == 0 || index == len(lines)-1 { - final = append(final, line) - continue - } - y := len(final) - if y >= selection.startY && y <= selection.endY { - left := 3 - if y == selection.startY { - left = selection.startX - 2 - } - left = max(3, left) - - width := ansi.StringWidth(line) - right := width - 1 - if y == selection.endY { - right = min(selection.endX-2, right) - } - - prefix := ansi.Cut(line, 0, left) - middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ") - suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width) - clipboard = append(clipboard, middle) - line = prefix + styles.NewStyle(). - Background(t.Accent()). - Foreground(t.BackgroundPanel()). - Render(ansi.Strip(middle)) + - suffix - } - final = append(final, line) - } - y := len(final) - if selection != nil && y >= selection.startY && y < selection.endY { - clipboard = append(clipboard, "") - } - final = append(final, "") - } - content := "\n" + strings.Join(final, "\n") - viewport.SetHeight(m.height - lipgloss.Height(header)) - viewport.SetContent(content) - if tail { - viewport.GotoBottom() - } - - return renderCompleteMsg{ - header: header, - clipboard: clipboard, - viewport: viewport, - partCount: partCount, - lineCount: lineCount, - messagePositions: messagePositions, - } - } -} - -func (m *messagesComponent) renderHeader() string { - if m.app.Session.ID == "" { - return "" - } - - headerWidth := m.width - - t := theme.CurrentTheme() - bgColor := t.Background() - borderColor := t.BackgroundElement() - - isChildSession := m.app.Session.ParentID != "" - if isChildSession { - bgColor = t.BackgroundElement() - borderColor = t.Accent() - } - - base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render - muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render - - sessionInfo := "" - tokens := float64(0) - cost := float64(0) - contextWindow := m.app.Model.Limit.Context - - for _, message := range m.app.Messages { - if assistant, ok := message.Info.(opencode.AssistantMessage); ok { - cost += assistant.Cost - usage := assistant.Tokens - if usage.Output > 0 { - if assistant.Summary { - tokens = usage.Output - continue - } - tokens = (usage.Input + - usage.Cache.Read + - usage.Cache.Write + - usage.Output + - usage.Reasoning) - } - } - } - - // Check if current model is a subscription model (cost is 0 for both input and output) - isSubscriptionModel := m.app.Model != nil && - m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0 - - sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel) - sessionInfo = styles.NewStyle(). - Foreground(t.TextMuted()). - Background(bgColor). - Render(sessionInfoText) - - shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled - - navHint := "" - if isChildSession { - navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back") - } - - headerTextWidth := headerWidth - if isChildSession { - headerTextWidth -= lipgloss.Width(navHint) - } else if !shareEnabled { - headerTextWidth -= lipgloss.Width(sessionInfoText) - } - headerText := util.ToMarkdown( - "# "+m.app.Session.Title, - headerTextWidth, - bgColor, - ) - if isChildSession { - headerText = layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: headerTextWidth, - }, - layout.FlexItem{ - View: headerText, - }, - layout.FlexItem{ - View: navHint, - }, - ) - } - - var items []layout.FlexItem - if shareEnabled { - share := base("/share") + muted(" to create a shareable link") - if m.app.Session.Share.URL != "" { - share = muted(m.app.Session.Share.URL + " /unshare") - } - items = []layout.FlexItem{{View: share}, {View: sessionInfo}} - } else { - items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}} - } - - headerRow := layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: headerWidth - 6, - }, - items..., - ) - - headerLines := []string{headerRow} - if shareEnabled { - headerLines = []string{headerText, headerRow} - } - - header := strings.Join(headerLines, "\n") - header = styles.NewStyle(). - Background(bgColor). - Width(headerWidth). - PaddingLeft(2). - PaddingRight(2). - BorderLeft(true). - BorderRight(true). - BorderBackground(t.Background()). - BorderForeground(borderColor). - BorderStyle(lipgloss.ThickBorder()). - Render(header) - - return "\n" + header + "\n" -} - -func formatTokensAndCost( - tokens float64, - contextWindow float64, - cost float64, - isSubscriptionModel bool, -) string { - // Format tokens in human-readable format (e.g., 110K, 1.2M) - var formattedTokens string - switch { - case tokens >= 1_000_000: - formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000) - case tokens >= 1_000: - formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000) - default: - formattedTokens = fmt.Sprintf("%d", int(tokens)) - } - - // Remove .0 suffix if present - if strings.HasSuffix(formattedTokens, ".0K") { - formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1) - } - if strings.HasSuffix(formattedTokens, ".0M") { - formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1) - } - - percentage := 0.0 - if contextWindow > 0 { - percentage = (float64(tokens) / float64(contextWindow)) * 100 - } - - if isSubscriptionModel { - return fmt.Sprintf( - "%s/%d%%", - formattedTokens, - int(percentage), - ) - } - - formattedCost := fmt.Sprintf("$%.2f", cost) - return fmt.Sprintf( - " %s/%d%% (%s)", - formattedTokens, - int(percentage), - formattedCost, - ) -} - -func (m *messagesComponent) View() string { - t := theme.CurrentTheme() - bgColor := t.Background() - - if m.loading { - return lipgloss.Place( - m.width, - m.height, - lipgloss.Center, - lipgloss.Center, - styles.NewStyle().Background(bgColor).Render(""), - styles.WhitespaceStyle(bgColor), - ) - } - - viewport := m.viewport.View() - return styles.NewStyle(). - Background(bgColor). - Render(m.header + "\n" + viewport) -} - -func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) { - m.viewport.ViewUp() - return m, nil -} - -func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) { - m.viewport.ViewDown() - return m, nil -} - -func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) { - m.viewport.HalfViewUp() - return m, nil -} - -func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) { - m.viewport.HalfViewDown() - return m, nil -} - -func (m *messagesComponent) ToolDetailsVisible() bool { - return m.showToolDetails -} - -func (m *messagesComponent) ThinkingBlocksVisible() bool { - return m.showThinkingBlocks -} - -func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) { - m.viewport.GotoTop() - return m, nil -} - -func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) { - m.viewport.GotoBottom() - return m, nil -} - -func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) { - if len(m.app.Messages) == 0 { - return m, nil - } - lastMessage := m.app.Messages[len(m.app.Messages)-1] - var lastTextPart *opencode.TextPart - for _, part := range lastMessage.Parts { - if p, ok := part.(opencode.TextPart); ok { - lastTextPart = &p - } - } - if lastTextPart == nil { - return m, nil - } - var cmds []tea.Cmd - cmds = append(cmds, app.SetClipboard(lastTextPart.Text)) - cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard")) - return m, tea.Batch(cmds...) -} - -func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) { - after := float64(0) - var revertedMessage app.Message - reversedMessages := []app.Message{} - for i := len(m.app.Messages) - 1; i >= 0; i-- { - reversedMessages = append(reversedMessages, m.app.Messages[i]) - switch casted := m.app.Messages[i].Info.(type) { - case opencode.UserMessage: - if casted.ID == m.app.Session.Revert.MessageID { - after = casted.Time.Created - } - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - after = casted.Time.Created - } - } - if m.app.Session.Revert.PartID != "" { - for _, part := range m.app.Messages[i].Parts { - switch casted := part.(type) { - case opencode.TextPart: - if casted.ID == m.app.Session.Revert.PartID { - after = casted.Time.Start - } - case opencode.ToolPart: - // TODO: handle tool parts - } - } - } - } - - messageID := "" - for _, msg := range reversedMessages { - switch casted := msg.Info.(type) { - case opencode.UserMessage: - if after > 0 && casted.Time.Created >= after { - continue - } - messageID = casted.ID - revertedMessage = msg - } - if messageID != "" { - break - } - } - - if messageID == "" { - return m, nil - } - - return m, func() tea.Msg { - response, err := m.app.Client.Session.Revert( - context.Background(), - m.app.Session.ID, - opencode.SessionRevertParams{ - MessageID: opencode.F(messageID), - }, - ) - if err != nil { - slog.Error("Failed to undo message", "error", err) - return toast.NewErrorToast("Failed to undo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to undo message")() - } - return app.MessageRevertedMsg{Session: *response, Message: revertedMessage} - } -} - -func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) { - // Check if there's a revert state to redo from - if m.app.Session.Revert.MessageID == "" { - return m, func() tea.Msg { - return toast.NewErrorToast("Nothing to redo") - } - } - - before := float64(0) - var revertedMessage app.Message - for _, message := range m.app.Messages { - switch casted := message.Info.(type) { - case opencode.UserMessage: - if casted.ID == m.app.Session.Revert.MessageID { - before = casted.Time.Created - } - case opencode.AssistantMessage: - if casted.ID == m.app.Session.Revert.MessageID { - before = casted.Time.Created - } - } - if m.app.Session.Revert.PartID != "" { - for _, part := range message.Parts { - switch casted := part.(type) { - case opencode.TextPart: - if casted.ID == m.app.Session.Revert.PartID { - before = casted.Time.Start - } - case opencode.ToolPart: - // TODO: handle tool parts - } - } - } - } - - messageID := "" - for _, msg := range m.app.Messages { - switch casted := msg.Info.(type) { - case opencode.UserMessage: - if casted.Time.Created <= before { - continue - } - messageID = casted.ID - revertedMessage = msg - } - if messageID != "" { - break - } - } - - if messageID == "" { - return m, func() tea.Msg { - // unrevert back to original state - response, err := m.app.Client.Session.Unrevert( - context.Background(), - m.app.Session.ID, - opencode.SessionUnrevertParams{}, - ) - if err != nil { - slog.Error("Failed to unrevert session", "error", err) - return toast.NewErrorToast("Failed to redo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to redo message")() - } - return app.SessionUnrevertedMsg{Session: *response} - } - } - - return m, func() tea.Msg { - // calling revert on a "later" message is like a redo - response, err := m.app.Client.Session.Revert( - context.Background(), - m.app.Session.ID, - opencode.SessionRevertParams{ - MessageID: opencode.F(messageID), - }, - ) - if err != nil { - slog.Error("Failed to redo message", "error", err) - return toast.NewErrorToast("Failed to redo message")() - } - if response == nil { - return toast.NewErrorToast("Failed to redo message")() - } - return app.MessageRevertedMsg{Session: *response, Message: revertedMessage} - } -} - -func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) { - if m.messagePositions == nil { - return m, nil - } - - if position, exists := m.messagePositions[messageID]; exists { - m.viewport.SetYOffset(position) - m.tail = false // Stop auto-scrolling to bottom when manually navigating - } - return m, nil -} - -func NewMessagesComponent(app *app.App) MessagesComponent { - vp := viewport.New() - vp.KeyMap = viewport.KeyMap{} - - if app.ScrollSpeed > 0 { - vp.MouseWheelDelta = app.ScrollSpeed - } else { - vp.MouseWheelDelta = 2 - } - - // Default to showing tool details, hidden thinking blocks - showToolDetails := true - if app.State.ShowToolDetails != nil { - showToolDetails = *app.State.ShowToolDetails - } - - showThinkingBlocks := false - if app.State.ShowThinkingBlocks != nil { - showThinkingBlocks = *app.State.ShowThinkingBlocks - } - - return &messagesComponent{ - app: app, - viewport: vp, - showToolDetails: showToolDetails, - showThinkingBlocks: showThinkingBlocks, - cache: NewPartCache(), - tail: true, - messagePositions: make(map[string]int), - } -} |
