| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178 |
- 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
- 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)
- }
- 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
- }
- 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{}
- 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
- 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, 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.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.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()
- // 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())
- }
- }
- 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
- error := ""
- 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)
- 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
- 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,
- 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,
- []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,
- []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
- content = renderText(
- m.app,
- message.Info,
- text,
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- true,
- []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.ProviderAuthError:
- error = err.Data.Message
- case opencode.MessageAbortedError:
- error = "Request was aborted"
- case opencode.UnknownError:
- error = err.Data.Message
- }
- if !hasContent && error == "" && !reverted {
- content = renderText(
- m.app,
- message.Info,
- "Generating...",
- casted.ModelID,
- m.showToolDetails,
- width,
- "",
- 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,
- )
- 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,
- )
- 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)
- 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) 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,
- )
- 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 = 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,
- }
- }
|