messages.go 29 KB

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