messages.go 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320
  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.ID == m.app.Session.Revert.MessageID {
  445. reverted = true
  446. revertedMessageCount = 1
  447. revertedToolCount = 0
  448. }
  449. hasTextPart := false
  450. hasContent := false
  451. for partIndex, p := range message.Parts {
  452. switch part := p.(type) {
  453. case opencode.TextPart:
  454. if reverted {
  455. continue
  456. }
  457. if strings.TrimSpace(part.Text) == "" {
  458. continue
  459. }
  460. hasTextPart = true
  461. finished := part.Time.End > 0
  462. remainingParts := message.Parts[partIndex+1:]
  463. toolCallParts := make([]opencode.ToolPart, 0)
  464. // sometimes tool calls happen without an assistant message
  465. // these should be included in this assistant message as well
  466. if len(orphanedToolCalls) > 0 {
  467. toolCallParts = append(toolCallParts, orphanedToolCalls...)
  468. orphanedToolCalls = make([]opencode.ToolPart, 0)
  469. }
  470. remaining := true
  471. for _, part := range remainingParts {
  472. if !remaining {
  473. break
  474. }
  475. switch part := part.(type) {
  476. case opencode.TextPart:
  477. // we only want tool calls associated with the current text part.
  478. // if we hit another text part, we're done.
  479. remaining = false
  480. case opencode.ToolPart:
  481. toolCallParts = append(toolCallParts, part)
  482. if part.State.Status != opencode.ToolPartStateStatusCompleted && part.State.Status != opencode.ToolPartStateStatusError {
  483. // i don't think there's a case where a tool call isn't in result state
  484. // and the message time is 0, but just in case
  485. finished = false
  486. }
  487. }
  488. }
  489. if finished {
  490. key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
  491. content, cached = m.cache.Get(key)
  492. if !cached {
  493. content = renderText(
  494. m.app,
  495. message.Info,
  496. part.Text,
  497. casted.ModelID,
  498. m.showToolDetails,
  499. width,
  500. "",
  501. false,
  502. false,
  503. false,
  504. []opencode.FilePart{},
  505. []opencode.AgentPart{},
  506. toolCallParts...,
  507. )
  508. m.cache.Set(key, content)
  509. }
  510. } else {
  511. content = renderText(
  512. m.app,
  513. message.Info,
  514. part.Text,
  515. casted.ModelID,
  516. m.showToolDetails,
  517. width,
  518. "",
  519. false,
  520. false,
  521. false,
  522. []opencode.FilePart{},
  523. []opencode.AgentPart{},
  524. toolCallParts...,
  525. )
  526. }
  527. if content != "" {
  528. partCount++
  529. lineCount += lipgloss.Height(content) + 1
  530. blocks = append(blocks, content)
  531. hasContent = true
  532. }
  533. case opencode.ToolPart:
  534. if reverted {
  535. revertedToolCount++
  536. continue
  537. }
  538. permission := opencode.Permission{}
  539. if m.app.CurrentPermission.CallID == part.CallID {
  540. permission = m.app.CurrentPermission
  541. }
  542. if !m.showToolDetails && permission.ID == "" {
  543. if !hasTextPart {
  544. orphanedToolCalls = append(orphanedToolCalls, part)
  545. }
  546. continue
  547. }
  548. if part.State.Status == opencode.ToolPartStateStatusCompleted || part.State.Status == opencode.ToolPartStateStatusError {
  549. key := m.cache.GenerateKey(casted.ID,
  550. part.ID,
  551. m.showToolDetails,
  552. width,
  553. permission.ID,
  554. )
  555. content, cached = m.cache.Get(key)
  556. if !cached {
  557. content = renderToolDetails(
  558. m.app,
  559. part,
  560. permission,
  561. width,
  562. )
  563. m.cache.Set(key, content)
  564. }
  565. } else {
  566. // if the tool call isn't finished, don't cache
  567. content = renderToolDetails(
  568. m.app,
  569. part,
  570. permission,
  571. width,
  572. )
  573. }
  574. if content != "" {
  575. partCount++
  576. lineCount += lipgloss.Height(content) + 1
  577. blocks = append(blocks, content)
  578. hasContent = true
  579. }
  580. case opencode.ReasoningPart:
  581. if reverted {
  582. continue
  583. }
  584. if !m.showThinkingBlocks {
  585. continue
  586. }
  587. if part.Text != "" {
  588. text := part.Text
  589. shimmer := part.Time.End == 0 && part.ID == lastStreamingReasoningID
  590. content = renderText(
  591. m.app,
  592. message.Info,
  593. text,
  594. casted.ModelID,
  595. m.showToolDetails,
  596. width,
  597. "",
  598. true,
  599. false,
  600. shimmer,
  601. []opencode.FilePart{},
  602. []opencode.AgentPart{},
  603. )
  604. partCount++
  605. lineCount += lipgloss.Height(content) + 1
  606. blocks = append(blocks, content)
  607. hasContent = true
  608. }
  609. }
  610. }
  611. switch err := casted.Error.AsUnion().(type) {
  612. case nil:
  613. case opencode.AssistantMessageErrorMessageOutputLengthError:
  614. error = "Message output length exceeded"
  615. case opencode.ProviderAuthError:
  616. error = err.Data.Message
  617. case opencode.MessageAbortedError:
  618. error = "Request was aborted"
  619. case opencode.UnknownError:
  620. error = err.Data.Message
  621. }
  622. if !hasContent && error == "" && !reverted && casted.Time.Completed == 0 {
  623. content = renderText(
  624. m.app,
  625. message.Info,
  626. "Generating...",
  627. casted.ModelID,
  628. m.showToolDetails,
  629. width,
  630. "",
  631. false,
  632. false,
  633. false,
  634. []opencode.FilePart{},
  635. []opencode.AgentPart{},
  636. )
  637. partCount++
  638. lineCount += lipgloss.Height(content) + 1
  639. blocks = append(blocks, content)
  640. }
  641. }
  642. if error != "" && !reverted {
  643. error = styles.NewStyle().Width(width - 6).Render(error)
  644. error = renderContentBlock(
  645. m.app,
  646. error,
  647. width,
  648. WithBorderColor(t.Error()),
  649. )
  650. blocks = append(blocks, error)
  651. lineCount += lipgloss.Height(error) + 1
  652. }
  653. }
  654. if revertedMessageCount > 0 || revertedToolCount > 0 {
  655. messagePlural := ""
  656. toolPlural := ""
  657. if revertedMessageCount != 1 {
  658. messagePlural = "s"
  659. }
  660. if revertedToolCount != 1 {
  661. toolPlural = "s"
  662. }
  663. revertedStyle := styles.NewStyle().
  664. Background(t.BackgroundPanel()).
  665. Foreground(t.TextMuted())
  666. content := revertedStyle.Render(fmt.Sprintf(
  667. "%d message%s reverted, %d tool call%s reverted",
  668. revertedMessageCount,
  669. messagePlural,
  670. revertedToolCount,
  671. toolPlural,
  672. ))
  673. hintStyle := styles.NewStyle().Background(t.BackgroundPanel()).Foreground(t.Text())
  674. hint := hintStyle.Render(m.app.Keybind(commands.MessagesRedoCommand))
  675. hint += revertedStyle.Render(" (or /redo) to restore")
  676. content += "\n" + hint
  677. if m.app.Session.Revert.Diff != "" {
  678. t := theme.CurrentTheme()
  679. s := styles.NewStyle().Background(t.BackgroundPanel())
  680. green := s.Foreground(t.Success()).Render
  681. red := s.Foreground(t.Error()).Render
  682. content += "\n"
  683. stats, err := diff.ParseStats(m.app.Session.Revert.Diff)
  684. if err != nil {
  685. slog.Error("Failed to parse diff stats", "error", err)
  686. } else {
  687. var files []string
  688. for file := range stats {
  689. files = append(files, file)
  690. }
  691. sort.Strings(files)
  692. for _, file := range files {
  693. fileStats := stats[file]
  694. display := file
  695. if fileStats.Added > 0 {
  696. display += green(" +" + strconv.Itoa(int(fileStats.Added)))
  697. }
  698. if fileStats.Removed > 0 {
  699. display += red(" -" + strconv.Itoa(int(fileStats.Removed)))
  700. }
  701. content += "\n" + display
  702. }
  703. }
  704. }
  705. content = styles.NewStyle().
  706. Background(t.BackgroundPanel()).
  707. Width(width - 6).
  708. Render(content)
  709. content = renderContentBlock(
  710. m.app,
  711. content,
  712. width,
  713. WithBorderColor(t.BackgroundPanel()),
  714. )
  715. blocks = append(blocks, content)
  716. }
  717. if m.app.CurrentPermission.ID != "" &&
  718. m.app.CurrentPermission.SessionID != m.app.Session.ID {
  719. response, err := m.app.Client.Session.Message(
  720. context.Background(),
  721. m.app.CurrentPermission.SessionID,
  722. m.app.CurrentPermission.MessageID,
  723. opencode.SessionMessageParams{},
  724. )
  725. if err != nil || response == nil {
  726. slog.Error("Failed to get message from child session", "error", err)
  727. } else {
  728. for _, part := range response.Parts {
  729. if part.CallID == m.app.CurrentPermission.CallID {
  730. if toolPart, ok := part.AsUnion().(opencode.ToolPart); ok {
  731. content := renderToolDetails(
  732. m.app,
  733. toolPart,
  734. m.app.CurrentPermission,
  735. width,
  736. )
  737. if content != "" {
  738. partCount++
  739. lineCount += lipgloss.Height(content) + 1
  740. blocks = append(blocks, content)
  741. }
  742. }
  743. }
  744. }
  745. }
  746. }
  747. final := []string{}
  748. clipboard := []string{}
  749. var selection *selection
  750. if m.selection != nil {
  751. selection = m.selection.coords(lipgloss.Height(header) + 1)
  752. }
  753. for _, block := range blocks {
  754. lines := strings.Split(block, "\n")
  755. for index, line := range lines {
  756. if selection == nil || index == 0 || index == len(lines)-1 {
  757. final = append(final, line)
  758. continue
  759. }
  760. y := len(final)
  761. if y >= selection.startY && y <= selection.endY {
  762. left := 3
  763. if y == selection.startY {
  764. left = selection.startX - 2
  765. }
  766. left = max(3, left)
  767. width := ansi.StringWidth(line)
  768. right := width - 1
  769. if y == selection.endY {
  770. right = min(selection.endX-2, right)
  771. }
  772. prefix := ansi.Cut(line, 0, left)
  773. middle := strings.TrimRight(ansi.Strip(ansi.Cut(line, left, right)), " ")
  774. suffix := ansi.Cut(line, left+ansi.StringWidth(middle), width)
  775. clipboard = append(clipboard, middle)
  776. line = prefix + styles.NewStyle().
  777. Background(t.Accent()).
  778. Foreground(t.BackgroundPanel()).
  779. Render(ansi.Strip(middle)) +
  780. suffix
  781. }
  782. final = append(final, line)
  783. }
  784. y := len(final)
  785. if selection != nil && y >= selection.startY && y < selection.endY {
  786. clipboard = append(clipboard, "")
  787. }
  788. final = append(final, "")
  789. }
  790. content := "\n" + strings.Join(final, "\n")
  791. viewport.SetHeight(m.height - lipgloss.Height(header))
  792. viewport.SetContent(content)
  793. if tail {
  794. viewport.GotoBottom()
  795. }
  796. return renderCompleteMsg{
  797. header: header,
  798. clipboard: clipboard,
  799. viewport: viewport,
  800. partCount: partCount,
  801. lineCount: lineCount,
  802. messagePositions: messagePositions,
  803. }
  804. }
  805. }
  806. func (m *messagesComponent) renderHeader() string {
  807. if m.app.Session.ID == "" {
  808. return ""
  809. }
  810. headerWidth := m.width
  811. t := theme.CurrentTheme()
  812. bgColor := t.Background()
  813. borderColor := t.BackgroundElement()
  814. isChildSession := m.app.Session.ParentID != ""
  815. if isChildSession {
  816. bgColor = t.BackgroundElement()
  817. borderColor = t.Accent()
  818. }
  819. base := styles.NewStyle().Foreground(t.Text()).Background(bgColor).Render
  820. muted := styles.NewStyle().Foreground(t.TextMuted()).Background(bgColor).Render
  821. sessionInfo := ""
  822. tokens := float64(0)
  823. cost := float64(0)
  824. contextWindow := m.app.Model.Limit.Context
  825. for _, message := range m.app.Messages {
  826. if assistant, ok := message.Info.(opencode.AssistantMessage); ok {
  827. cost += assistant.Cost
  828. usage := assistant.Tokens
  829. if usage.Output > 0 {
  830. if assistant.Summary {
  831. tokens = usage.Output
  832. continue
  833. }
  834. tokens = (usage.Input +
  835. usage.Cache.Read +
  836. usage.Cache.Write +
  837. usage.Output +
  838. usage.Reasoning)
  839. }
  840. }
  841. }
  842. // Check if current model is a subscription model (cost is 0 for both input and output)
  843. isSubscriptionModel := m.app.Model != nil &&
  844. m.app.Model.Cost.Input == 0 && m.app.Model.Cost.Output == 0
  845. sessionInfoText := formatTokensAndCost(tokens, contextWindow, cost, isSubscriptionModel)
  846. sessionInfo = styles.NewStyle().
  847. Foreground(t.TextMuted()).
  848. Background(bgColor).
  849. Render(sessionInfoText)
  850. shareEnabled := m.app.Config.Share != opencode.ConfigShareDisabled
  851. navHint := ""
  852. if isChildSession {
  853. navHint = base(" "+m.app.Keybind(commands.SessionChildCycleReverseCommand)) + muted(" back")
  854. }
  855. headerTextWidth := headerWidth
  856. if isChildSession {
  857. headerTextWidth -= lipgloss.Width(navHint)
  858. } else if !shareEnabled {
  859. headerTextWidth -= lipgloss.Width(sessionInfoText)
  860. }
  861. headerText := util.ToMarkdown(
  862. "# "+m.app.Session.Title,
  863. headerTextWidth,
  864. bgColor,
  865. )
  866. if isChildSession {
  867. headerText = layout.Render(
  868. layout.FlexOptions{
  869. Background: &bgColor,
  870. Direction: layout.Row,
  871. Justify: layout.JustifySpaceBetween,
  872. Align: layout.AlignStretch,
  873. Width: headerTextWidth,
  874. },
  875. layout.FlexItem{
  876. View: headerText,
  877. },
  878. layout.FlexItem{
  879. View: navHint,
  880. },
  881. )
  882. }
  883. var items []layout.FlexItem
  884. if shareEnabled {
  885. share := base("/share") + muted(" to create a shareable link")
  886. if m.app.Session.Share.URL != "" {
  887. share = muted(m.app.Session.Share.URL + " /unshare")
  888. }
  889. items = []layout.FlexItem{{View: share}, {View: sessionInfo}}
  890. } else {
  891. items = []layout.FlexItem{{View: headerText}, {View: sessionInfo}}
  892. }
  893. headerRow := layout.Render(
  894. layout.FlexOptions{
  895. Background: &bgColor,
  896. Direction: layout.Row,
  897. Justify: layout.JustifySpaceBetween,
  898. Align: layout.AlignStretch,
  899. Width: headerWidth - 6,
  900. },
  901. items...,
  902. )
  903. headerLines := []string{headerRow}
  904. if shareEnabled {
  905. headerLines = []string{headerText, headerRow}
  906. }
  907. header := strings.Join(headerLines, "\n")
  908. header = styles.NewStyle().
  909. Background(bgColor).
  910. Width(headerWidth).
  911. PaddingLeft(2).
  912. PaddingRight(2).
  913. BorderLeft(true).
  914. BorderRight(true).
  915. BorderBackground(t.Background()).
  916. BorderForeground(borderColor).
  917. BorderStyle(lipgloss.ThickBorder()).
  918. Render(header)
  919. return "\n" + header + "\n"
  920. }
  921. func formatTokensAndCost(
  922. tokens float64,
  923. contextWindow float64,
  924. cost float64,
  925. isSubscriptionModel bool,
  926. ) string {
  927. // Format tokens in human-readable format (e.g., 110K, 1.2M)
  928. var formattedTokens string
  929. switch {
  930. case tokens >= 1_000_000:
  931. formattedTokens = fmt.Sprintf("%.1fM", float64(tokens)/1_000_000)
  932. case tokens >= 1_000:
  933. formattedTokens = fmt.Sprintf("%.1fK", float64(tokens)/1_000)
  934. default:
  935. formattedTokens = fmt.Sprintf("%d", int(tokens))
  936. }
  937. // Remove .0 suffix if present
  938. if strings.HasSuffix(formattedTokens, ".0K") {
  939. formattedTokens = strings.Replace(formattedTokens, ".0K", "K", 1)
  940. }
  941. if strings.HasSuffix(formattedTokens, ".0M") {
  942. formattedTokens = strings.Replace(formattedTokens, ".0M", "M", 1)
  943. }
  944. percentage := 0.0
  945. if contextWindow > 0 {
  946. percentage = (float64(tokens) / float64(contextWindow)) * 100
  947. }
  948. if isSubscriptionModel {
  949. return fmt.Sprintf(
  950. "%s/%d%%",
  951. formattedTokens,
  952. int(percentage),
  953. )
  954. }
  955. formattedCost := fmt.Sprintf("$%.2f", cost)
  956. return fmt.Sprintf(
  957. " %s/%d%% (%s)",
  958. formattedTokens,
  959. int(percentage),
  960. formattedCost,
  961. )
  962. }
  963. func (m *messagesComponent) View() string {
  964. t := theme.CurrentTheme()
  965. bgColor := t.Background()
  966. if m.loading {
  967. return lipgloss.Place(
  968. m.width,
  969. m.height,
  970. lipgloss.Center,
  971. lipgloss.Center,
  972. styles.NewStyle().Background(bgColor).Render(""),
  973. styles.WhitespaceStyle(bgColor),
  974. )
  975. }
  976. viewport := m.viewport.View()
  977. return styles.NewStyle().
  978. Background(bgColor).
  979. Render(m.header + "\n" + viewport)
  980. }
  981. func (m *messagesComponent) PageUp() (tea.Model, tea.Cmd) {
  982. m.viewport.ViewUp()
  983. return m, nil
  984. }
  985. func (m *messagesComponent) PageDown() (tea.Model, tea.Cmd) {
  986. m.viewport.ViewDown()
  987. return m, nil
  988. }
  989. func (m *messagesComponent) HalfPageUp() (tea.Model, tea.Cmd) {
  990. m.viewport.HalfViewUp()
  991. return m, nil
  992. }
  993. func (m *messagesComponent) HalfPageDown() (tea.Model, tea.Cmd) {
  994. m.viewport.HalfViewDown()
  995. return m, nil
  996. }
  997. func (m *messagesComponent) ToolDetailsVisible() bool {
  998. return m.showToolDetails
  999. }
  1000. func (m *messagesComponent) ThinkingBlocksVisible() bool {
  1001. return m.showThinkingBlocks
  1002. }
  1003. func (m *messagesComponent) GotoTop() (tea.Model, tea.Cmd) {
  1004. m.viewport.GotoTop()
  1005. return m, nil
  1006. }
  1007. func (m *messagesComponent) GotoBottom() (tea.Model, tea.Cmd) {
  1008. m.viewport.GotoBottom()
  1009. return m, nil
  1010. }
  1011. func (m *messagesComponent) CopyLastMessage() (tea.Model, tea.Cmd) {
  1012. if len(m.app.Messages) == 0 {
  1013. return m, nil
  1014. }
  1015. lastMessage := m.app.Messages[len(m.app.Messages)-1]
  1016. var lastTextPart *opencode.TextPart
  1017. for _, part := range lastMessage.Parts {
  1018. if p, ok := part.(opencode.TextPart); ok {
  1019. lastTextPart = &p
  1020. }
  1021. }
  1022. if lastTextPart == nil {
  1023. return m, nil
  1024. }
  1025. var cmds []tea.Cmd
  1026. cmds = append(cmds, app.SetClipboard(lastTextPart.Text))
  1027. cmds = append(cmds, toast.NewSuccessToast("Message copied to clipboard"))
  1028. return m, tea.Batch(cmds...)
  1029. }
  1030. func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
  1031. after := float64(0)
  1032. var revertedMessage app.Message
  1033. reversedMessages := []app.Message{}
  1034. for i := len(m.app.Messages) - 1; i >= 0; i-- {
  1035. reversedMessages = append(reversedMessages, m.app.Messages[i])
  1036. switch casted := m.app.Messages[i].Info.(type) {
  1037. case opencode.UserMessage:
  1038. if casted.ID == m.app.Session.Revert.MessageID {
  1039. after = casted.Time.Created
  1040. }
  1041. case opencode.AssistantMessage:
  1042. if casted.ID == m.app.Session.Revert.MessageID {
  1043. after = casted.Time.Created
  1044. }
  1045. }
  1046. if m.app.Session.Revert.PartID != "" {
  1047. for _, part := range m.app.Messages[i].Parts {
  1048. switch casted := part.(type) {
  1049. case opencode.TextPart:
  1050. if casted.ID == m.app.Session.Revert.PartID {
  1051. after = casted.Time.Start
  1052. }
  1053. case opencode.ToolPart:
  1054. // TODO: handle tool parts
  1055. }
  1056. }
  1057. }
  1058. }
  1059. messageID := ""
  1060. for _, msg := range reversedMessages {
  1061. switch casted := msg.Info.(type) {
  1062. case opencode.UserMessage:
  1063. if after > 0 && casted.Time.Created >= after {
  1064. continue
  1065. }
  1066. messageID = casted.ID
  1067. revertedMessage = msg
  1068. }
  1069. if messageID != "" {
  1070. break
  1071. }
  1072. }
  1073. if messageID == "" {
  1074. return m, nil
  1075. }
  1076. return m, func() tea.Msg {
  1077. response, err := m.app.Client.Session.Revert(
  1078. context.Background(),
  1079. m.app.Session.ID,
  1080. opencode.SessionRevertParams{
  1081. MessageID: opencode.F(messageID),
  1082. },
  1083. )
  1084. if err != nil {
  1085. slog.Error("Failed to undo message", "error", err)
  1086. return toast.NewErrorToast("Failed to undo message")()
  1087. }
  1088. if response == nil {
  1089. return toast.NewErrorToast("Failed to undo message")()
  1090. }
  1091. return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
  1092. }
  1093. }
  1094. func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
  1095. // Check if there's a revert state to redo from
  1096. if m.app.Session.Revert.MessageID == "" {
  1097. return m, func() tea.Msg {
  1098. return toast.NewErrorToast("Nothing to redo")
  1099. }
  1100. }
  1101. before := float64(0)
  1102. var revertedMessage app.Message
  1103. for _, message := range m.app.Messages {
  1104. switch casted := message.Info.(type) {
  1105. case opencode.UserMessage:
  1106. if casted.ID == m.app.Session.Revert.MessageID {
  1107. before = casted.Time.Created
  1108. }
  1109. case opencode.AssistantMessage:
  1110. if casted.ID == m.app.Session.Revert.MessageID {
  1111. before = casted.Time.Created
  1112. }
  1113. }
  1114. if m.app.Session.Revert.PartID != "" {
  1115. for _, part := range message.Parts {
  1116. switch casted := part.(type) {
  1117. case opencode.TextPart:
  1118. if casted.ID == m.app.Session.Revert.PartID {
  1119. before = casted.Time.Start
  1120. }
  1121. case opencode.ToolPart:
  1122. // TODO: handle tool parts
  1123. }
  1124. }
  1125. }
  1126. }
  1127. messageID := ""
  1128. for _, msg := range m.app.Messages {
  1129. switch casted := msg.Info.(type) {
  1130. case opencode.UserMessage:
  1131. if casted.Time.Created <= before {
  1132. continue
  1133. }
  1134. messageID = casted.ID
  1135. revertedMessage = msg
  1136. }
  1137. if messageID != "" {
  1138. break
  1139. }
  1140. }
  1141. if messageID == "" {
  1142. return m, func() tea.Msg {
  1143. // unrevert back to original state
  1144. response, err := m.app.Client.Session.Unrevert(
  1145. context.Background(),
  1146. m.app.Session.ID,
  1147. opencode.SessionUnrevertParams{},
  1148. )
  1149. if err != nil {
  1150. slog.Error("Failed to unrevert session", "error", err)
  1151. return toast.NewErrorToast("Failed to redo message")()
  1152. }
  1153. if response == nil {
  1154. return toast.NewErrorToast("Failed to redo message")()
  1155. }
  1156. return app.SessionUnrevertedMsg{Session: *response}
  1157. }
  1158. }
  1159. return m, func() tea.Msg {
  1160. // calling revert on a "later" message is like a redo
  1161. response, err := m.app.Client.Session.Revert(
  1162. context.Background(),
  1163. m.app.Session.ID,
  1164. opencode.SessionRevertParams{
  1165. MessageID: opencode.F(messageID),
  1166. },
  1167. )
  1168. if err != nil {
  1169. slog.Error("Failed to redo message", "error", err)
  1170. return toast.NewErrorToast("Failed to redo message")()
  1171. }
  1172. if response == nil {
  1173. return toast.NewErrorToast("Failed to redo message")()
  1174. }
  1175. return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
  1176. }
  1177. }
  1178. func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) {
  1179. if m.messagePositions == nil {
  1180. return m, nil
  1181. }
  1182. if position, exists := m.messagePositions[messageID]; exists {
  1183. m.viewport.SetYOffset(position)
  1184. m.tail = false // Stop auto-scrolling to bottom when manually navigating
  1185. }
  1186. return m, nil
  1187. }
  1188. func NewMessagesComponent(app *app.App) MessagesComponent {
  1189. vp := viewport.New()
  1190. vp.KeyMap = viewport.KeyMap{}
  1191. if app.ScrollSpeed > 0 {
  1192. vp.MouseWheelDelta = app.ScrollSpeed
  1193. } else {
  1194. vp.MouseWheelDelta = 2
  1195. }
  1196. // Default to showing tool details, hidden thinking blocks
  1197. showToolDetails := true
  1198. if app.State.ShowToolDetails != nil {
  1199. showToolDetails = *app.State.ShowToolDetails
  1200. }
  1201. showThinkingBlocks := false
  1202. if app.State.ShowThinkingBlocks != nil {
  1203. showThinkingBlocks = *app.State.ShowThinkingBlocks
  1204. }
  1205. return &messagesComponent{
  1206. app: app,
  1207. viewport: vp,
  1208. showToolDetails: showToolDetails,
  1209. showThinkingBlocks: showThinkingBlocks,
  1210. cache: NewPartCache(),
  1211. tail: true,
  1212. messagePositions: make(map[string]int),
  1213. }
  1214. }