messages.go 34 KB

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