package chat import ( "context" "fmt" "log/slog" "slices" "sort" "strconv" "strings" 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 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) } type messagesComponent struct { width, height int app *app.App header string viewport viewport.Model clipboard []string cache *PartCache loading bool showToolDetails bool rendering bool dirty bool tail bool partCount int lineCount int selection *selection } 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{} 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 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 && len(m.clipboard) > 0 { content := strings.Join(m.clipboard, "\n") m.selection = nil m.clipboard = []string{} return m, tea.Sequence( m.renderView(), app.SetClipboard(content), toast.NewSuccessToast("Copied to clipboard"), ) } 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 dialog.ThemeSelectedMsg: m.cache.Clear() m.loading = true return m, m.renderView() case ToggleToolDetailsMsg: m.showToolDetails = !m.showToolDetails return m, m.renderView() case app.SessionLoadedMsg, 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.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.EventListResponseEventMessagePartUpdated: if msg.Properties.Part.SessionID == m.app.Session.ID { 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.tail = m.viewport.AtBottom() m.viewport = msg.viewport m.header = msg.header if m.dirty { cmds = append(cmds, m.renderView()) } } 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 } 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 orphanedToolCalls := make([]opencode.ToolPart, 0) width := m.width // always use full width reverted := false revertedMessageCount := 0 revertedToolCount := 0 lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" for _, msg := range slices.Backward(m.app.Messages) { if assistant, ok := msg.Info.(opencode.AssistantMessage); ok { lastAssistantMessage = assistant.ID break } } for _, message := range m.app.Messages { var content string var cached bool switch casted := message.Info.(type) { case opencode.UserMessage: 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) for _, part := range remainingParts { switch part := part.(type) { case opencode.FilePart: fileParts = append(fileParts, 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 if casted.ID > lastAssistantMessage { author += " [queued]" } key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author) content, cached = m.cache.Get(key) if !cached { content = renderText( m.app, message.Info, part.Text, author, m.showToolDetails, width, files, fileParts, ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) 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 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) content, cached = m.cache.Get(key) if !cached { content = renderText( m.app, message.Info, part.Text, casted.ModelID, m.showToolDetails, width, "", []opencode.FilePart{}, toolCallParts..., ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) m.cache.Set(key, content) } } else { content = renderText( m.app, message.Info, part.Text, casted.ModelID, m.showToolDetails, width, "", []opencode.FilePart{}, toolCallParts..., ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) } if content != "" { partCount++ lineCount += lipgloss.Height(content) + 1 blocks = append(blocks, content) } 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, ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) m.cache.Set(key, content) } } else { // if the tool call isn't finished, don't cache content = renderToolDetails( m.app, part, permission, width, ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) } if content != "" { partCount++ lineCount += lipgloss.Height(content) + 1 blocks = append(blocks, content) } } } } error := "" if assistant, ok := message.Info.(opencode.AssistantMessage); ok { switch err := assistant.Error.AsUnion().(type) { case nil: case opencode.AssistantMessageErrorMessageOutputLengthError: error = "Message output length exceeded" case opencode.ProviderAuthError: error = err.Data.Message case opencode.MessageAbortedError: error = "Request was aborted" case opencode.UnknownError: error = err.Data.Message } } if error != "" && !reverted { error = styles.NewStyle().Width(width - 6).Render(error) error = renderContentBlock( m.app, error, width, WithBorderColor(t.Error()), ) error = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, error, styles.WhitespaceStyle(t.Background()), ) 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, ) 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 { content := renderToolDetails( m.app, part.AsUnion().(opencode.ToolPart), m.app.CurrentPermission, width, ) content = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, content, styles.WhitespaceStyle(t.Background()), ) 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, } } } func (m *messagesComponent) renderHeader() string { if m.app.Session.ID == "" { return "" } headerWidth := m.width t := theme.CurrentTheme() base := styles.NewStyle().Foreground(t.Text()).Background(t.Background()).Render muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).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.Write + usage.Cache.Read + 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(t.Background()). Render(sessionInfoText) shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled headerTextWidth := headerWidth if !shareEnabled { // +1 is to ensure there is always at least one space between header and session info headerTextWidth -= len(sessionInfoText) + 1 } headerText := util.ToMarkdown( "# "+m.app.Session.Title, headerTextWidth, t.Background(), ) 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}} } background := t.Background() headerRow := layout.Render( layout.FlexOptions{ Background: &background, 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(t.Background()). Width(headerWidth). PaddingLeft(2). PaddingRight(2). BorderLeft(true). BorderRight(true). BorderBackground(t.Background()). BorderForeground(t.BackgroundElement()). BorderStyle(lipgloss.ThickBorder()). Render(header) header = lipgloss.PlaceHorizontal( m.width, lipgloss.Center, header, styles.WhitespaceStyle(t.Background()), ) 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() if m.loading { return lipgloss.Place( m.width, m.height, lipgloss.Center, lipgloss.Center, styles.NewStyle().Background(t.Background()).Render(""), styles.WhitespaceStyle(t.Background()), ) } viewport := m.viewport.View() return styles.NewStyle(). Background(t.Background()). 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) 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, ) 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 NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() vp.KeyMap = viewport.KeyMap{} if app.State.ScrollSpeed != nil && *app.State.ScrollSpeed > 0 { vp.MouseWheelDelta = *app.State.ScrollSpeed } else { vp.MouseWheelDelta = 4 } return &messagesComponent{ app: app, viewport: vp, showToolDetails: true, cache: NewPartCache(), tail: true, } }