messages.go 30 KB

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