messages.go 34 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315
  1. package chat
  2. import (
  3. "context"
  4. "fmt"
  5. "log/slog"
  6. "slices"
  7. "sort"
  8. "strconv"
  9. "strings"
  10. "time"
  11. tea "github.com/charmbracelet/bubbletea/v2"
  12. "github.com/charmbracelet/lipgloss/v2"
  13. "github.com/charmbracelet/x/ansi"
  14. "github.com/sst/opencode-sdk-go"
  15. "github.com/sst/opencode/internal/app"
  16. "github.com/sst/opencode/internal/commands"
  17. "github.com/sst/opencode/internal/components/dialog"
  18. "github.com/sst/opencode/internal/components/diff"
  19. "github.com/sst/opencode/internal/components/toast"
  20. "github.com/sst/opencode/internal/layout"
  21. "github.com/sst/opencode/internal/styles"
  22. "github.com/sst/opencode/internal/theme"
  23. "github.com/sst/opencode/internal/util"
  24. "github.com/sst/opencode/internal/viewport"
  25. )
  26. type MessagesComponent interface {
  27. tea.Model
  28. tea.ViewModel
  29. PageUp() (tea.Model, tea.Cmd)
  30. PageDown() (tea.Model, tea.Cmd)
  31. HalfPageUp() (tea.Model, tea.Cmd)
  32. HalfPageDown() (tea.Model, tea.Cmd)
  33. ToolDetailsVisible() bool
  34. ThinkingBlocksVisible() bool
  35. GotoTop() (tea.Model, tea.Cmd)
  36. GotoBottom() (tea.Model, tea.Cmd)
  37. CopyLastMessage() (tea.Model, tea.Cmd)
  38. UndoLastMessage() (tea.Model, tea.Cmd)
  39. RedoLastMessage() (tea.Model, tea.Cmd)
  40. ScrollToMessage(messageID string) (tea.Model, tea.Cmd)
  41. }
  42. type messagesComponent struct {
  43. width, height int
  44. app *app.App
  45. header string
  46. viewport viewport.Model
  47. clipboard []string
  48. cache *PartCache
  49. loading bool
  50. showToolDetails bool
  51. showThinkingBlocks bool
  52. rendering bool
  53. dirty bool
  54. tail bool
  55. partCount int
  56. lineCount int
  57. selection *selection
  58. messagePositions map[string]int // map message ID to line position
  59. animating bool
  60. }
  61. type selection struct {
  62. startX int
  63. endX int
  64. startY int
  65. endY int
  66. }
  67. func (s selection) coords(offset int) *selection {
  68. // selecting backwards
  69. if s.startY > s.endY && s.endY >= 0 {
  70. return &selection{
  71. startX: max(0, s.endX-1),
  72. startY: s.endY - offset,
  73. endX: s.startX + 1,
  74. endY: s.startY - offset,
  75. }
  76. }
  77. // selecting backwards same line
  78. if s.startY == s.endY && s.startX >= s.endX {
  79. return &selection{
  80. startY: s.startY - offset,
  81. startX: max(0, s.endX-1),
  82. endY: s.endY - offset,
  83. endX: s.startX + 1,
  84. }
  85. }
  86. return &selection{
  87. startX: s.startX,
  88. startY: s.startY - offset,
  89. endX: s.endX,
  90. endY: s.endY - offset,
  91. }
  92. }
  93. type ToggleToolDetailsMsg struct{}
  94. type ToggleThinkingBlocksMsg struct{}
  95. type shimmerTickMsg struct{}
  96. func (m *messagesComponent) Init() tea.Cmd {
  97. return tea.Batch(m.viewport.Init())
  98. }
  99. func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  100. var cmds []tea.Cmd
  101. switch msg := msg.(type) {
  102. case shimmerTickMsg:
  103. if !m.app.HasAnimatingWork() {
  104. m.animating = false
  105. return m, nil
  106. }
  107. return m, tea.Sequence(
  108. m.renderView(),
  109. tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }),
  110. )
  111. case tea.MouseClickMsg:
  112. slog.Info("mouse", "x", msg.X, "y", msg.Y, "offset", m.viewport.YOffset)
  113. y := msg.Y + m.viewport.YOffset
  114. if y > 0 {
  115. m.selection = &selection{
  116. startY: y,
  117. startX: msg.X,
  118. endY: -1,
  119. endX: -1,
  120. }
  121. 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))
  122. return m, m.renderView()
  123. }
  124. case tea.MouseMotionMsg:
  125. if m.selection != nil {
  126. m.selection = &selection{
  127. startX: m.selection.startX,
  128. startY: m.selection.startY,
  129. endX: msg.X + 1,
  130. endY: msg.Y + m.viewport.YOffset,
  131. }
  132. return m, m.renderView()
  133. }
  134. case tea.MouseReleaseMsg:
  135. if m.selection != nil {
  136. m.selection = nil
  137. if len(m.clipboard) > 0 {
  138. content := strings.Join(m.clipboard, "\n")
  139. m.clipboard = []string{}
  140. return m, tea.Sequence(
  141. m.renderView(),
  142. app.SetClipboard(content),
  143. toast.NewSuccessToast("Copied to clipboard"),
  144. )
  145. }
  146. return m, m.renderView()
  147. }
  148. case tea.WindowSizeMsg:
  149. effectiveWidth := msg.Width - 4
  150. // Clear cache on resize since width affects rendering
  151. if m.width != effectiveWidth {
  152. m.cache.Clear()
  153. }
  154. m.width = effectiveWidth
  155. m.height = msg.Height - 7
  156. m.viewport.SetWidth(m.width)
  157. m.loading = true
  158. return m, m.renderView()
  159. case app.SendPrompt:
  160. m.viewport.GotoBottom()
  161. m.tail = true
  162. return m, nil
  163. case app.SendCommand:
  164. m.viewport.GotoBottom()
  165. m.tail = true
  166. return m, nil
  167. case dialog.ThemeSelectedMsg:
  168. m.cache.Clear()
  169. m.loading = true
  170. return m, m.renderView()
  171. case ToggleToolDetailsMsg:
  172. m.showToolDetails = !m.showToolDetails
  173. m.app.State.ShowToolDetails = &m.showToolDetails
  174. return m, tea.Batch(m.renderView(), m.app.SaveState())
  175. case ToggleThinkingBlocksMsg:
  176. m.showThinkingBlocks = !m.showThinkingBlocks
  177. m.app.State.ShowThinkingBlocks = &m.showThinkingBlocks
  178. return m, tea.Batch(m.renderView(), m.app.SaveState())
  179. case app.SessionLoadedMsg:
  180. m.tail = true
  181. m.loading = true
  182. return m, m.renderView()
  183. case app.SessionClearedMsg:
  184. m.cache.Clear()
  185. m.tail = true
  186. m.loading = true
  187. return m, m.renderView()
  188. case app.SessionUnrevertedMsg:
  189. if msg.Session.ID == m.app.Session.ID {
  190. m.cache.Clear()
  191. m.tail = true
  192. return m, m.renderView()
  193. }
  194. case app.SessionSelectedMsg:
  195. currentParent := m.app.Session.ParentID
  196. if currentParent == "" {
  197. currentParent = m.app.Session.ID
  198. }
  199. targetParent := msg.ParentID
  200. if targetParent == "" {
  201. targetParent = msg.ID
  202. }
  203. // Clear cache only if switching between different session families
  204. if currentParent != targetParent {
  205. m.cache.Clear()
  206. }
  207. m.viewport.GotoBottom()
  208. case app.MessageRevertedMsg:
  209. if msg.Session.ID == m.app.Session.ID {
  210. m.cache.Clear()
  211. m.tail = true
  212. return m, m.renderView()
  213. }
  214. case opencode.EventListResponseEventSessionUpdated:
  215. if msg.Properties.Info.ID == m.app.Session.ID {
  216. cmds = append(cmds, m.renderView())
  217. }
  218. case opencode.EventListResponseEventMessageUpdated:
  219. if msg.Properties.Info.SessionID == m.app.Session.ID {
  220. cmds = append(cmds, m.renderView())
  221. }
  222. case opencode.EventListResponseEventSessionError:
  223. if msg.Properties.SessionID == m.app.Session.ID {
  224. cmds = append(cmds, m.renderView())
  225. }
  226. case opencode.EventListResponseEventMessagePartUpdated:
  227. if msg.Properties.Part.SessionID == m.app.Session.ID {
  228. cmds = append(cmds, m.renderView())
  229. }
  230. case opencode.EventListResponseEventMessageRemoved:
  231. if msg.Properties.SessionID == m.app.Session.ID {
  232. m.cache.Clear()
  233. cmds = append(cmds, m.renderView())
  234. }
  235. case opencode.EventListResponseEventMessagePartRemoved:
  236. if msg.Properties.SessionID == m.app.Session.ID {
  237. // Clear the cache when a part is removed to ensure proper re-rendering
  238. m.cache.Clear()
  239. cmds = append(cmds, m.renderView())
  240. }
  241. case opencode.EventListResponseEventPermissionUpdated:
  242. m.tail = true
  243. return m, m.renderView()
  244. case opencode.EventListResponseEventPermissionReplied:
  245. m.tail = true
  246. return m, m.renderView()
  247. case renderCompleteMsg:
  248. m.partCount = msg.partCount
  249. m.lineCount = msg.lineCount
  250. m.rendering = false
  251. m.clipboard = msg.clipboard
  252. m.loading = false
  253. m.messagePositions = msg.messagePositions
  254. m.tail = m.viewport.AtBottom()
  255. // Preserve scroll across reflow
  256. // if the user was at bottom, keep following; otherwise restore the previous offset.
  257. wasAtBottom := m.viewport.AtBottom()
  258. prevYOffset := m.viewport.YOffset
  259. m.viewport = msg.viewport
  260. if wasAtBottom {
  261. m.viewport.GotoBottom()
  262. } else {
  263. m.viewport.YOffset = prevYOffset
  264. }
  265. m.header = msg.header
  266. if m.dirty {
  267. cmds = append(cmds, m.renderView())
  268. }
  269. // Start shimmer ticks if any assistant/tool is in-flight
  270. if !m.animating && m.app.HasAnimatingWork() {
  271. m.animating = true
  272. cmds = append(cmds, tea.Tick(90*time.Millisecond, func(t time.Time) tea.Msg { return shimmerTickMsg{} }))
  273. }
  274. }
  275. m.tail = m.viewport.AtBottom()
  276. viewport, cmd := m.viewport.Update(msg)
  277. m.viewport = viewport
  278. cmds = append(cmds, cmd)
  279. return m, tea.Batch(cmds...)
  280. }
  281. type renderCompleteMsg struct {
  282. viewport viewport.Model
  283. clipboard []string
  284. header string
  285. partCount int
  286. lineCount int
  287. messagePositions map[string]int
  288. }
  289. func (m *messagesComponent) renderView() tea.Cmd {
  290. if m.rendering {
  291. slog.Debug("pending render, skipping")
  292. m.dirty = true
  293. return func() tea.Msg {
  294. return nil
  295. }
  296. }
  297. m.dirty = false
  298. m.rendering = true
  299. viewport := m.viewport
  300. tail := m.tail
  301. return func() tea.Msg {
  302. header := m.renderHeader()
  303. measure := util.Measure("messages.renderView")
  304. defer measure()
  305. t := theme.CurrentTheme()
  306. blocks := make([]string, 0)
  307. partCount := 0
  308. lineCount := 0
  309. messagePositions := make(map[string]int) // Track message ID to line position
  310. orphanedToolCalls := make([]opencode.ToolPart, 0)
  311. width := m.width // always use full width
  312. // Find the last streaming ReasoningPart to only shimmer that one
  313. lastStreamingReasoningID := ""
  314. if m.showThinkingBlocks {
  315. for mi := len(m.app.Messages) - 1; mi >= 0 && lastStreamingReasoningID == ""; mi-- {
  316. if _, ok := m.app.Messages[mi].Info.(opencode.AssistantMessage); !ok {
  317. continue
  318. }
  319. parts := m.app.Messages[mi].Parts
  320. for pi := len(parts) - 1; pi >= 0; pi-- {
  321. if rp, ok := parts[pi].(opencode.ReasoningPart); ok {
  322. if strings.TrimSpace(rp.Text) != "" && rp.Time.End == 0 {
  323. lastStreamingReasoningID = rp.ID
  324. break
  325. }
  326. }
  327. }
  328. }
  329. }
  330. reverted := false
  331. revertedMessageCount := 0
  332. revertedToolCount := 0
  333. lastAssistantMessage := "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
  334. for _, msg := range slices.Backward(m.app.Messages) {
  335. if assistant, ok := msg.Info.(opencode.AssistantMessage); ok {
  336. lastAssistantMessage = assistant.ID
  337. break
  338. }
  339. }
  340. for _, message := range m.app.Messages {
  341. var content string
  342. var cached bool
  343. error := ""
  344. switch casted := message.Info.(type) {
  345. case opencode.UserMessage:
  346. // Track the position of this user message
  347. messagePositions[casted.ID] = lineCount
  348. if casted.ID == m.app.Session.Revert.MessageID {
  349. reverted = true
  350. revertedMessageCount = 1
  351. revertedToolCount = 0
  352. continue
  353. }
  354. if reverted {
  355. revertedMessageCount++
  356. continue
  357. }
  358. for partIndex, part := range message.Parts {
  359. switch part := part.(type) {
  360. case opencode.TextPart:
  361. if part.Synthetic {
  362. continue
  363. }
  364. if part.Text == "" {
  365. continue
  366. }
  367. remainingParts := message.Parts[partIndex+1:]
  368. fileParts := make([]opencode.FilePart, 0)
  369. agentParts := make([]opencode.AgentPart, 0)
  370. for _, part := range remainingParts {
  371. switch part := part.(type) {
  372. case opencode.FilePart:
  373. if part.Source.Text.Start >= 0 && part.Source.Text.End >= part.Source.Text.Start {
  374. fileParts = append(fileParts, part)
  375. }
  376. case opencode.AgentPart:
  377. if part.Source.Start >= 0 && part.Source.End >= part.Source.Start {
  378. agentParts = append(agentParts, part)
  379. }
  380. }
  381. }
  382. flexItems := []layout.FlexItem{}
  383. if len(fileParts) > 0 {
  384. fileStyle := styles.NewStyle().Background(t.BackgroundElement()).Foreground(t.TextMuted()).Padding(0, 1)
  385. mediaTypeStyle := styles.NewStyle().Background(t.Secondary()).Foreground(t.BackgroundPanel()).Padding(0, 1)
  386. for _, filePart := range fileParts {
  387. mediaType := ""
  388. switch filePart.Mime {
  389. case "text/plain":
  390. mediaType = "txt"
  391. case "image/png", "image/jpeg", "image/gif", "image/webp":
  392. mediaType = "img"
  393. mediaTypeStyle = mediaTypeStyle.Background(t.Accent())
  394. case "application/pdf":
  395. mediaType = "pdf"
  396. mediaTypeStyle = mediaTypeStyle.Background(t.Primary())
  397. }
  398. flexItems = append(flexItems, layout.FlexItem{
  399. View: mediaTypeStyle.Render(mediaType) + fileStyle.Render(filePart.Filename),
  400. })
  401. }
  402. }
  403. bgColor := t.BackgroundPanel()
  404. files := layout.Render(
  405. layout.FlexOptions{
  406. Background: &bgColor,
  407. Width: width - 6,
  408. Direction: layout.Column,
  409. },
  410. flexItems...,
  411. )
  412. author := m.app.Config.Username
  413. isQueued := casted.ID > lastAssistantMessage
  414. key := m.cache.GenerateKey(casted.ID, part.Text, width, files, author, isQueued)
  415. content, cached = m.cache.Get(key)
  416. if !cached {
  417. content = renderText(
  418. m.app,
  419. message.Info,
  420. part.Text,
  421. author,
  422. m.showToolDetails,
  423. width,
  424. files,
  425. false,
  426. isQueued,
  427. false,
  428. fileParts,
  429. agentParts,
  430. )
  431. m.cache.Set(key, content)
  432. }
  433. if content != "" {
  434. partCount++
  435. lineCount += lipgloss.Height(content) + 1
  436. blocks = append(blocks, content)
  437. }
  438. }
  439. }
  440. case opencode.AssistantMessage:
  441. if casted.ID == m.app.Session.Revert.MessageID {
  442. reverted = true
  443. revertedMessageCount = 1
  444. revertedToolCount = 0
  445. }
  446. hasTextPart := false
  447. hasContent := false
  448. for partIndex, p := range message.Parts {
  449. switch part := p.(type) {
  450. case opencode.TextPart:
  451. if reverted {
  452. continue
  453. }
  454. if strings.TrimSpace(part.Text) == "" {
  455. continue
  456. }
  457. hasTextPart = true
  458. finished := part.Time.End > 0
  459. remainingParts := message.Parts[partIndex+1:]
  460. toolCallParts := make([]opencode.ToolPart, 0)
  461. // sometimes tool calls happen without an assistant message
  462. // these should be included in this assistant message as well
  463. if len(orphanedToolCalls) > 0 {
  464. toolCallParts = append(toolCallParts, orphanedToolCalls...)
  465. orphanedToolCalls = make([]opencode.ToolPart, 0)
  466. }
  467. remaining := true
  468. for _, part := range remainingParts {
  469. if !remaining {
  470. break
  471. }
  472. switch part := part.(type) {
  473. case opencode.TextPart:
  474. // we only want tool calls associated with the current text part.
  475. // if we hit another text part, we're done.
  476. remaining = false
  477. case opencode.ToolPart:
  478. toolCallParts = append(toolCallParts, part)
  479. if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
  480. // i don't think there's a case where a tool call isn't in result state
  481. // and the message time is 0, but just in case
  482. finished = false
  483. }
  484. }
  485. }
  486. if finished {
  487. key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
  488. content, cached = m.cache.Get(key)
  489. if !cached {
  490. content = renderText(
  491. m.app,
  492. message.Info,
  493. part.Text,
  494. casted.ModelID,
  495. m.showToolDetails,
  496. width,
  497. "",
  498. false,
  499. false,
  500. false,
  501. []opencode.FilePart{},
  502. []opencode.AgentPart{},
  503. toolCallParts...,
  504. )
  505. m.cache.Set(key, content)
  506. }
  507. } else {
  508. content = renderText(
  509. m.app,
  510. message.Info,
  511. part.Text,
  512. casted.ModelID,
  513. m.showToolDetails,
  514. width,
  515. "",
  516. false,
  517. false,
  518. false,
  519. []opencode.FilePart{},
  520. []opencode.AgentPart{},
  521. toolCallParts...,
  522. )
  523. }
  524. if content != "" {
  525. partCount++
  526. lineCount += lipgloss.Height(content) + 1
  527. blocks = append(blocks, content)
  528. hasContent = true
  529. }
  530. case opencode.ToolPart:
  531. if reverted {
  532. revertedToolCount++
  533. continue
  534. }
  535. permission := opencode.Permission{}
  536. if m.app.CurrentPermission.CallID == part.CallID {
  537. permission = m.app.CurrentPermission
  538. }
  539. if !m.showToolDetails && permission.ID == "" {
  540. if !hasTextPart {
  541. orphanedToolCalls = append(orphanedToolCalls, part)
  542. }
  543. continue
  544. }
  545. if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
  546. key := m.cache.GenerateKey(casted.ID,
  547. part.ID,
  548. m.showToolDetails,
  549. width,
  550. permission.ID,
  551. )
  552. content, cached = m.cache.Get(key)
  553. if !cached {
  554. content = renderToolDetails(
  555. m.app,
  556. part,
  557. permission,
  558. width,
  559. )
  560. m.cache.Set(key, content)
  561. }
  562. } else {
  563. // if the tool call isn't finished, don't cache
  564. content = renderToolDetails(
  565. m.app,
  566. part,
  567. permission,
  568. width,
  569. )
  570. }
  571. if content != "" {
  572. partCount++
  573. lineCount += lipgloss.Height(content) + 1
  574. blocks = append(blocks, content)
  575. hasContent = true
  576. }
  577. case opencode.ReasoningPart:
  578. if reverted {
  579. continue
  580. }
  581. if !m.showThinkingBlocks {
  582. continue
  583. }
  584. if part.Text != "" {
  585. text := part.Text
  586. shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
  587. content = renderText(
  588. m.app,
  589. message.Info,
  590. text,
  591. casted.ModelID,
  592. m.showToolDetails,
  593. width,
  594. "",
  595. true,
  596. false,
  597. shimmer,
  598. []opencode.FilePart{},
  599. []opencode.AgentPart{},
  600. )
  601. partCount++
  602. lineCount += lipgloss.Height(content) + 1
  603. blocks = append(blocks, content)
  604. hasContent = true
  605. }
  606. }
  607. }
  608. switch err := casted.Error.AsUnion().(type) {
  609. case nil:
  610. case opencode.AssistantMessageErrorMessageOutputLengthError:
  611. error = "Message output length exceeded"
  612. case opencode.ProviderAuthError:
  613. error = err.Data.Message
  614. case opencode.MessageAbortedError:
  615. error = "Request was aborted"
  616. case opencode.UnknownError:
  617. error = err.Data.Message
  618. }
  619. if !hasContent && error == "" && !reverted {
  620. content = renderText(
  621. m.app,
  622. message.Info,
  623. "Generating...",
  624. casted.ModelID,
  625. m.showToolDetails,
  626. width,
  627. "",
  628. false,
  629. false,
  630. false,
  631. []opencode.FilePart{},
  632. []opencode.AgentPart{},
  633. )
  634. partCount++
  635. lineCount += lipgloss.Height(content) + 1
  636. blocks = append(blocks, content)
  637. }
  638. }
  639. if error != "" && !reverted {
  640. error = styles.NewStyle().Width(width - 6).Render(error)
  641. error = renderContentBlock(
  642. m.app,
  643. error,
  644. width,
  645. WithBorderColor(t.Error()),
  646. )
  647. blocks = append(blocks, error)
  648. lineCount += lipgloss.Height(error) + 1
  649. }
  650. }
  651. if revertedMessageCount > 0 || revertedToolCount > 0 {
  652. messagePlural := ""
  653. toolPlural := ""
  654. if revertedMessageCount != 1 {
  655. messagePlural = "s"
  656. }
  657. if revertedToolCount != 1 {
  658. toolPlural = "s"
  659. }
  660. revertedStyle := styles.NewStyle().
  661. Background(t.BackgroundPanel()).
  662. Foreground(t.TextMuted())
  663. content := revertedStyle.Render(fmt.Sprintf(
  664. "%d message%s reverted, %d tool call%s reverted",
  665. revertedMessageCount,
  666. messagePlural,
  667. revertedToolCount,
  668. toolPlural,
  669. ))
  670. hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
  671. hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
  672. hint += revertedStyle.Render(" (or /redo) to restore")
  673. content += "\n" + hint
  674. if m.app.Session.Revert.Diff != "" {
  675. t := theme.CurrentTheme()
  676. s := styles.NewStyle().Background(t.BackgroundPanel())
  677. green := s.Foreground(t.Success()).Render
  678. red := s.Foreground(t.Error()).Render
  679. content += "\n"
  680. stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
  681. if err != nil {
  682. slog.Error("Failed to parse diff stats", "error", err)
  683. } else {
  684. var files []string
  685. for file := range stats {
  686. files = append(files, file)
  687. }
  688. sort.Strings(files)
  689. for _, file := range files {
  690. fileStats := stats[file]
  691. display := file
  692. if fileStats.Added > 0 {
  693. display += green(" +" + strconv.Itoa(int(fileStats.Added)))
  694. }
  695. if fileStats.Removed > 0 {
  696. display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
  697. }
  698. content += "\n" + display
  699. }
  700. }
  701. }
  702. content = styles.NewStyle().
  703. Background(t.BackgroundPanel()).
  704. Width(width - 6).
  705. Render(content)
  706. content = renderContentBlock(
  707. m.app,
  708. content,
  709. width,
  710. WithBorderColor(t.BackgroundPanel()),
  711. )
  712. blocks = append(blocks, content)
  713. }
  714. if m.app.CurrentPermission.ID != "" &&
  715. m.app.CurrentPermission.SessionID != m.app.Session.ID {
  716. response, err := m.app.Client.Session.Message(
  717. context.Background(),
  718. m.app.CurrentPermission.SessionID,
  719. m.app.CurrentPermission.MessageID,
  720. )
  721. if err != nil || response == nil {
  722. slog.Error("Failed to get message from child session", "error", err)
  723. } else {
  724. for _, part := range response.Parts {
  725. if part.CallID == m.app.CurrentPermission.CallID {
  726. if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
  727. content := renderToolDetails(
  728. m.app,
  729. toolPart,
  730. m.app.CurrentPermission,
  731. width,
  732. )
  733. if content != "" {
  734. partCount++
  735. lineCount += lipgloss.Height(content) + 1
  736. blocks = append(blocks, content)
  737. }
  738. }
  739. }
  740. }
  741. }
  742. }
  743. final := []string{}
  744. clipboard := []string{}
  745. var selection *selection
  746. if m.selection != nil {
  747. selection = m.selection.coords(lipgloss.Height(header) + 1)
  748. }
  749. for _, block := range blocks {
  750. lines := strings.Split(block, "\n")
  751. for index, line := range lines {
  752. if selection == nil || index == 0 || index == len(lines)-1 {
  753. final = append(final, line)
  754. continue
  755. }
  756. y := len(final)
  757. if y >= selection.startY && y <= selection.endY {
  758. left := 3
  759. if y == selection.startY {
  760. left = selection.startX - 2
  761. }
  762. left = max(3, left)
  763. width := ansi.StringWidth(line)
  764. right := width - 1
  765. if y == selection.endY {
  766. right = min(selection.endX-2, right)
  767. }
  768. prefix := ansi.Cut(line, 0, left)
  769. middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
  770. suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
  771. clipboard = append(clipboard, middle)
  772. line = prefix + styles.NewStyle().
  773. Background(t.Accent()).
  774. Foreground(t.BackgroundPanel()).
  775. Render(ansi.Strip(middle)) +
  776. suffix
  777. }
  778. final = append(final, line)
  779. }
  780. y := len(final)
  781. if selection != nil && y >= selection.startY && y < selection.endY {
  782. clipboard = append(clipboard, "")
  783. }
  784. final = append(final, "")
  785. }
  786. content := "\n" + strings.Join(final, "\n")
  787. viewport.SetHeight(m.height - lipgloss.Height(header))
  788. viewport.SetContent(content)
  789. if tail {
  790. viewport.GotoBottom()
  791. }
  792. return renderCompleteMsg{
  793. header: header,
  794. clipboard: clipboard,
  795. viewport: viewport,
  796. partCount: partCount,
  797. lineCount: lineCount,
  798. messagePositions: messagePositions,
  799. }
  800. }
  801. }
  802. func (m *messagesComponent) renderHeader() string {
  803. if m.app.Session.ID == "" {
  804. return ""
  805. }
  806. headerWidth := m.width
  807. t := theme.CurrentTheme()
  808. bgColor := t.Background()
  809. borderColor := t.BackgroundElement()
  810. isChildSession := m.app.Session.ParentID != ""
  811. if isChildSession {
  812. bgColor = t.BackgroundElement()
  813. borderColor = t.Accent()
  814. }
  815. base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
  816. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
  817. sessionInfo := ""
  818. tokens := float64(0)
  819. cost := float64(0)
  820. contextWindow := m.app.Model.Limit.Context
  821. for _, message := range m.app.Messages {
  822. if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
  823. cost += assistant.Cost
  824. usage := assistant.Tokens
  825. if usage.Output > 0 {
  826. if assistant.Summary {
  827. tokens = usage.Output
  828. continue
  829. }
  830. tokens = (usage.Input +
  831. usage.Cache.Write +
  832. usage.Cache.Read +
  833. usage.Output +
  834. usage.Reasoning)
  835. }
  836. }
  837. }
  838. // Check if current model is a subscription model (cost is 0 for both input and output)
  839. isSubscriptionModel := m.app.Model != nil &&
  840. m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
  841. sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
  842. sessionInfo = styles.NewStyle().
  843. Foreground(t.TextMuted()).
  844. Background(bgColor).
  845. Render(sessionInfoText)
  846. shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
  847. navHint := ""
  848. if isChildSession {
  849. navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
  850. }
  851. headerTextWidth := headerWidth
  852. if isChildSession {
  853. headerTextWidth -= lipgloss.Width(navHint)
  854. } else if !shareEnabled {
  855. headerTextWidth -= lipgloss.Width(sessionInfoText)
  856. }
  857. headerText := util.ToMarkdown(
  858. "# "+m.app.Session.Title,
  859. headerTextWidth,
  860. bgColor,
  861. )
  862. if isChildSession {
  863. headerText = layout.Render(
  864. layout.FlexOptions{
  865. Background: &bgColor,
  866. Direction: layout.Row,
  867. Justify: layout.JustifySpaceBetween,
  868. Align: layout.AlignStretch,
  869. Width: headerTextWidth,
  870. },
  871. layout.FlexItem{
  872. View: headerText,
  873. },
  874. layout.FlexItem{
  875. View: navHint,
  876. },
  877. )
  878. }
  879. var items []layout.FlexItem
  880. if shareEnabled {
  881. share := base("/share") + muted(" to create a shareable link")
  882. if m.app.Session.Share.URL != "" {
  883. share = muted(m.app.Session.Share.URL + " /unshare")
  884. }
  885. items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
  886. } else {
  887. items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
  888. }
  889. headerRow := layout.Render(
  890. layout.FlexOptions{
  891. Background: &bgColor,
  892. Direction: layout.Row,
  893. Justify: layout.JustifySpaceBetween,
  894. Align: layout.AlignStretch,
  895. Width: headerWidth - 6,
  896. },
  897. items...,
  898. )
  899. headerLines := []string{headerRow}
  900. if shareEnabled {
  901. headerLines = []string{headerText, headerRow}
  902. }
  903. header := strings.Join(headerLines, "\n")
  904. header = styles.NewStyle().
  905. Background(bgColor).
  906. Width(headerWidth).
  907. PaddingLeft(2).
  908. PaddingRight(2).
  909. BorderLeft(true).
  910. BorderRight(true).
  911. BorderBackground(t.Background()).
  912. BorderForeground(borderColor).
  913. BorderStyle(lipgloss.ThickBorder()).
  914. Render(header)
  915. return "\n" + header + "\n"
  916. }
  917. func formatTokensAndCost(
  918. tokens float64,
  919. contextWindow float64,
  920. cost float64,
  921. isSubscriptionModel bool,
  922. ) string {
  923. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  924. var formattedTokens string
  925. switch {
  926. case tokens >= 1_000_000:
  927. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  928. case tokens >= 1_000:
  929. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  930. default:
  931. formattedTokens = fmt.Sprintf("%d", int(tokens))
  932. }
  933. // Remove .0 suffix if present
  934. if strings.HasSuffix(formattedTokens, ".0K") {
  935. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  936. }
  937. if strings.HasSuffix(formattedTokens, ".0M") {
  938. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  939. }
  940. percentage := 0.0
  941. if contextWindow > 0 {
  942. percentage = (float64(tokens) / float64(contextWindow)) * 100
  943. }
  944. if isSubscriptionModel {
  945. return fmt.Sprintf(
  946. "%s/%d%%",
  947. formattedTokens,
  948. int(percentage),
  949. )
  950. }
  951. formattedCost := fmt.Sprintf("$%.2f", cost)
  952. return fmt.Sprintf(
  953. " %s/%d%% (%s)",
  954. formattedTokens,
  955. int(percentage),
  956. formattedCost,
  957. )
  958. }
  959. func (m *messagesComponent) View() string {
  960. t := theme.CurrentTheme()
  961. bgColor := t.Background()
  962. if m.loading {
  963. return lipgloss.Place(
  964. m.width,
  965. m.height,
  966. lipgloss.Center,
  967. lipgloss.Center,
  968. styles.NewStyle().Background(bgColor).Render(""),
  969. styles.WhitespaceStyle(bgColor),
  970. )
  971. }
  972. viewport := m.viewport.View()
  973. return styles.NewStyle().
  974. Background(bgColor).
  975. Render(m.header + "\n" + viewport)
  976. }
  977. func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
  978. m.viewport.ViewUp()
  979. return m, nil
  980. }
  981. func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
  982. m.viewport.ViewDown()
  983. return m, nil
  984. }
  985. func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
  986. m.viewport.HalfViewUp()
  987. return m, nil
  988. }
  989. func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
  990. m.viewport.HalfViewDown()
  991. return m, nil
  992. }
  993. func (m *messagesComponent) ToolDetailsVisible() bool {
  994. return m.showToolDetails
  995. }
  996. func (m *messagesComponent) ThinkingBlocksVisible() bool {
  997. return m.showThinkingBlocks
  998. }
  999. func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
  1000. m.viewport.GotoTop()
  1001. return m, nil
  1002. }
  1003. func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
  1004. m.viewport.GotoBottom()
  1005. return m, nil
  1006. }
  1007. func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
  1008. if len(m.app.Messages) == 0 {
  1009. return m, nil
  1010. }
  1011. lastMessage := m.app.Messages[len(m.app.Messages)-1]
  1012. var lastTextPart *opencode.TextPart
  1013. for _, part := range lastMessage.Parts {
  1014. if p, ok := part.(opencode.TextPart); ok {
  1015. lastTextPart = &p
  1016. }
  1017. }
  1018. if lastTextPart == nil {
  1019. return m, nil
  1020. }
  1021. var cmds []tea.Cmd
  1022. cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
  1023. cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
  1024. return m, tea.Batch(cmds...)
  1025. }
  1026. func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
  1027. after := float64(0)
  1028. var revertedMessage app.Message
  1029. reversedMessages := []app.Message{}
  1030. for i := len(m.app.Messages) - 1; i >= 0; i-- {
  1031. reversedMessages = append(reversedMessages, m.app.Messages[i])
  1032. switch casted := m.app.Messages[i].Info.(type) {
  1033. case opencode.UserMessage:
  1034. if casted.ID == m.app.Session.Revert.MessageID {
  1035. after = casted.Time.Created
  1036. }
  1037. case opencode.AssistantMessage:
  1038. if casted.ID == m.app.Session.Revert.MessageID {
  1039. after = casted.Time.Created
  1040. }
  1041. }
  1042. if m.app.Session.Revert.PartID != "" {
  1043. for _, part := range m.app.Messages[i].Parts {
  1044. switch casted := part.(type) {
  1045. case opencode.TextPart:
  1046. if casted.ID == m.app.Session.Revert.PartID {
  1047. after = casted.Time.Start
  1048. }
  1049. case opencode.ToolPart:
  1050. // TODO: handle tool parts
  1051. }
  1052. }
  1053. }
  1054. }
  1055. messageID := ""
  1056. for _, msg := range reversedMessages {
  1057. switch casted := msg.Info.(type) {
  1058. case opencode.UserMessage:
  1059. if after > 0 && casted.Time.Created >= after {
  1060. continue
  1061. }
  1062. messageID = casted.ID
  1063. revertedMessage = msg
  1064. }
  1065. if messageID != "" {
  1066. break
  1067. }
  1068. }
  1069. if messageID == "" {
  1070. return m, nil
  1071. }
  1072. return m, func() tea.Msg {
  1073. response, err := m.app.Client.Session.Revert(
  1074. context.Background(),
  1075. m.app.Session.ID,
  1076. opencode.SessionRevertParams{
  1077. MessageID: opencode.F(messageID),
  1078. },
  1079. )
  1080. if err != nil {
  1081. slog.Error("Failed to undo message", "error", err)
  1082. return toast.NewErrorToast("Failed to undo message")
  1083. }
  1084. if response == nil {
  1085. return toast.NewErrorToast("Failed to undo message")
  1086. }
  1087. return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
  1088. }
  1089. }
  1090. func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
  1091. // Check if there's a revert state to redo from
  1092. if m.app.Session.Revert.MessageID == "" {
  1093. return m, func() tea.Msg {
  1094. return toast.NewErrorToast("Nothing to redo")
  1095. }
  1096. }
  1097. before := float64(0)
  1098. var revertedMessage app.Message
  1099. for _, message := range m.app.Messages {
  1100. switch casted := message.Info.(type) {
  1101. case opencode.UserMessage:
  1102. if casted.ID == m.app.Session.Revert.MessageID {
  1103. before = casted.Time.Created
  1104. }
  1105. case opencode.AssistantMessage:
  1106. if casted.ID == m.app.Session.Revert.MessageID {
  1107. before = casted.Time.Created
  1108. }
  1109. }
  1110. if m.app.Session.Revert.PartID != "" {
  1111. for _, part := range message.Parts {
  1112. switch casted := part.(type) {
  1113. case opencode.TextPart:
  1114. if casted.ID == m.app.Session.Revert.PartID {
  1115. before = casted.Time.Start
  1116. }
  1117. case opencode.ToolPart:
  1118. // TODO: handle tool parts
  1119. }
  1120. }
  1121. }
  1122. }
  1123. messageID := ""
  1124. for _, msg := range m.app.Messages {
  1125. switch casted := msg.Info.(type) {
  1126. case opencode.UserMessage:
  1127. if casted.Time.Created <= before {
  1128. continue
  1129. }
  1130. messageID = casted.ID
  1131. revertedMessage = msg
  1132. }
  1133. if messageID != "" {
  1134. break
  1135. }
  1136. }
  1137. if messageID == "" {
  1138. return m, func() tea.Msg {
  1139. // unrevert back to original state
  1140. response, err := m.app.Client.Session.Unrevert(
  1141. context.Background(),
  1142. m.app.Session.ID,
  1143. )
  1144. if err != nil {
  1145. slog.Error("Failed to unrevert session", "error", err)
  1146. return toast.NewErrorToast("Failed to redo message")
  1147. }
  1148. if response == nil {
  1149. return toast.NewErrorToast("Failed to redo message")
  1150. }
  1151. return app.SessionUnrevertedMsg{Session: *response}
  1152. }
  1153. }
  1154. return m, func() tea.Msg {
  1155. // calling revert on a "later" message is like a redo
  1156. response, err := m.app.Client.Session.Revert(
  1157. context.Background(),
  1158. m.app.Session.ID,
  1159. opencode.SessionRevertParams{
  1160. MessageID: opencode.F(messageID),
  1161. },
  1162. )
  1163. if err != nil {
  1164. slog.Error("Failed to redo message", "error", err)
  1165. return toast.NewErrorToast("Failed to redo message")
  1166. }
  1167. if response == nil {
  1168. return toast.NewErrorToast("Failed to redo message")
  1169. }
  1170. return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
  1171. }
  1172. }
  1173. func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
  1174. if m.messagePositions == nil {
  1175. return m, nil
  1176. }
  1177. if position, exists := m.messagePositions[messageID]; exists {
  1178. m.viewport.SetYOffset(position)
  1179. m.tail = false // Stop auto-scrolling to bottom when manually navigating
  1180. }
  1181. return m, nil
  1182. }
  1183. func NewMessagesComponent(app *app.App) MessagesComponent {
  1184. vp := viewport.New()
  1185. vp.KeyMap = viewport.KeyMap{}
  1186. if app.ScrollSpeed > 0 {
  1187. vp.MouseWheelDelta = app.ScrollSpeed
  1188. } else {
  1189. vp.MouseWheelDelta = 2
  1190. }
  1191. // Default to showing tool details, hidden thinking blocks
  1192. showToolDetails := true
  1193. if app.State.ShowToolDetails != nil {
  1194. showToolDetails = *app.State.ShowToolDetails
  1195. }
  1196. showThinkingBlocks := false
  1197. if app.State.ShowThinkingBlocks != nil {
  1198. showThinkingBlocks = *app.State.ShowThinkingBlocks
  1199. }
  1200. return &messagesComponent{
  1201. app: app,
  1202. viewport: vp,
  1203. showToolDetails: showToolDetails,
  1204. showThinkingBlocks: showThinkingBlocks,
  1205. cache: NewPartCache(),
  1206. tail: true,
  1207. messagePositions: make(map[string]int),
  1208. }
  1209. }