| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266 |
- 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)
- 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
- }
- 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 {
- 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 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())
- }
- }
- 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
- 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:
- // 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,
- 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,
- []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,
- []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,
- false,
- []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,
- 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,
- 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.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(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,
- )
- 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),
- }
- }
|