summaryrefslogtreecommitdiffhomepage
path: root/packages/tui/internal/components/chat
diff options
context:
space:
mode:
authorDax Raad <[email protected]>2025-11-02 18:43:17 -0500
committerDax Raad <[email protected]>2025-11-02 18:43:33 -0500
commitf68374ad2223ddc213bdea9519ca6a699819ee0e (patch)
tree04f0fe21b8e12cd62d7274961bb0cff64f966f40 /packages/tui/internal/components/chat
parent5e86c9b7916f75c7ad227b80eab18c7c54fc8ffe (diff)
downloadopencode-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.go62
-rw-r--r--packages/tui/internal/components/chat/editor.go906
-rw-r--r--packages/tui/internal/components/chat/message.go1031
-rw-r--r--packages/tui/internal/components/chat/messages.go1322
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),
- }
-}