messages.go 32 KB

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