timeline.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. package dialog
  2. import (
  3. "fmt"
  4. "strings"
  5. "time"
  6. tea "github.com/charmbracelet/bubbletea/v2"
  7. "github.com/charmbracelet/lipgloss/v2"
  8. "github.com/muesli/reflow/truncate"
  9. "github.com/sst/opencode-sdk-go"
  10. "github.com/sst/opencode/internal/app"
  11. "github.com/sst/opencode/internal/components/list"
  12. "github.com/sst/opencode/internal/components/modal"
  13. "github.com/sst/opencode/internal/layout"
  14. "github.com/sst/opencode/internal/styles"
  15. "github.com/sst/opencode/internal/theme"
  16. "github.com/sst/opencode/internal/util"
  17. )
  18. // TimelineDialog interface for the session timeline dialog
  19. type TimelineDialog interface {
  20. layout.Modal
  21. }
  22. // ScrollToMessageMsg is sent when a message should be scrolled to
  23. type ScrollToMessageMsg struct {
  24. MessageID string
  25. }
  26. // RestoreToMessageMsg is sent when conversation should be restored to a specific message
  27. type RestoreToMessageMsg struct {
  28. MessageID string
  29. Index int
  30. }
  31. // timelineItem represents a user message in the timeline list
  32. type timelineItem struct {
  33. messageID string
  34. content string
  35. timestamp time.Time
  36. index int // Index in the full message list
  37. toolCount int // Number of tools used in this message
  38. }
  39. func (n timelineItem) Render(
  40. selected bool,
  41. width int,
  42. isFirstInViewport bool,
  43. baseStyle styles.Style,
  44. isCurrent bool,
  45. ) string {
  46. t := theme.CurrentTheme()
  47. infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
  48. textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
  49. // Add dot after timestamp if this is the current message - only apply color when not selected
  50. var dot string
  51. var dotVisualLen int
  52. if isCurrent {
  53. if selected {
  54. dot = "● "
  55. } else {
  56. dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
  57. }
  58. dotVisualLen = 2 // "● " is 2 characters wide
  59. }
  60. // Format timestamp - only apply color when not selected
  61. var timeStr string
  62. var timeVisualLen int
  63. if selected {
  64. timeStr = n.timestamp.Format("15:04") + " " + dot
  65. timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
  66. } else {
  67. timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
  68. timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
  69. }
  70. // Tool count display (fixed width for alignment) - only apply color when not selected
  71. toolInfo := ""
  72. toolInfoVisualLen := 0
  73. if n.toolCount > 0 {
  74. toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
  75. if selected {
  76. toolInfo = toolInfoText
  77. } else {
  78. toolInfo = infoStyle(toolInfoText)
  79. }
  80. toolInfoVisualLen = lipgloss.Width(toolInfo)
  81. }
  82. // Calculate available space for content
  83. // Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
  84. reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
  85. contentWidth := max(width-reservedSpace, 8)
  86. truncatedContent := truncate.StringWithTail(
  87. strings.Split(n.content, "\n")[0],
  88. uint(contentWidth),
  89. "...",
  90. )
  91. // Apply normal text color to content for non-selected items
  92. var styledContent string
  93. if selected {
  94. styledContent = truncatedContent
  95. } else {
  96. styledContent = textStyle(truncatedContent)
  97. }
  98. // Create the line with proper spacing - content left-aligned, tools right-aligned
  99. var text string
  100. text = timeStr + styledContent
  101. if toolInfo != "" {
  102. bgColor := t.BackgroundPanel()
  103. if selected {
  104. bgColor = t.Primary()
  105. }
  106. text = layout.Render(
  107. layout.FlexOptions{
  108. Background: &bgColor,
  109. Direction: layout.Row,
  110. Justify: layout.JustifySpaceBetween,
  111. Align: layout.AlignStretch,
  112. Width: width - 2,
  113. },
  114. layout.FlexItem{
  115. View: text,
  116. },
  117. layout.FlexItem{
  118. View: toolInfo,
  119. },
  120. )
  121. }
  122. var itemStyle styles.Style
  123. if selected {
  124. itemStyle = baseStyle.
  125. Background(t.Primary()).
  126. Foreground(t.BackgroundElement()).
  127. Width(width).
  128. PaddingLeft(1)
  129. } else {
  130. itemStyle = baseStyle.PaddingLeft(1)
  131. }
  132. return itemStyle.Render(text)
  133. }
  134. func (n timelineItem) Selectable() bool {
  135. return true
  136. }
  137. type timelineDialog struct {
  138. width int
  139. height int
  140. modal *modal.Modal
  141. list list.List[timelineItem]
  142. app *app.App
  143. }
  144. func (n *timelineDialog) Init() tea.Cmd {
  145. return nil
  146. }
  147. func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  148. switch msg := msg.(type) {
  149. case tea.WindowSizeMsg:
  150. n.width = msg.Width
  151. n.height = msg.Height
  152. n.list.SetMaxWidth(layout.Current.Container.Width - 12)
  153. case tea.KeyPressMsg:
  154. switch msg.String() {
  155. case "up", "down":
  156. // Handle navigation and immediately scroll to selected message
  157. var cmd tea.Cmd
  158. listModel, cmd := n.list.Update(msg)
  159. n.list = listModel.(list.List[timelineItem])
  160. // Get the newly selected item and scroll to it immediately
  161. if item, idx := n.list.GetSelectedItem(); idx >= 0 {
  162. return n, tea.Sequence(
  163. cmd,
  164. util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
  165. )
  166. }
  167. return n, cmd
  168. case "r":
  169. // Restore conversation to selected message
  170. if item, idx := n.list.GetSelectedItem(); idx >= 0 {
  171. return n, tea.Sequence(
  172. util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
  173. util.CmdHandler(modal.CloseModalMsg{}),
  174. )
  175. }
  176. case "enter":
  177. // Keep Enter functionality for closing the modal
  178. if _, idx := n.list.GetSelectedItem(); idx >= 0 {
  179. return n, util.CmdHandler(modal.CloseModalMsg{})
  180. }
  181. }
  182. }
  183. var cmd tea.Cmd
  184. listModel, cmd := n.list.Update(msg)
  185. n.list = listModel.(list.List[timelineItem])
  186. return n, cmd
  187. }
  188. func (n *timelineDialog) Render(background string) string {
  189. listView := n.list.View()
  190. t := theme.CurrentTheme()
  191. keyStyle := styles.NewStyle().
  192. Foreground(t.Text()).
  193. Background(t.BackgroundPanel()).
  194. Bold(true).
  195. Render
  196. mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
  197. helpText := keyStyle(
  198. "↑/↓",
  199. ) + mutedStyle(
  200. " jump ",
  201. ) + keyStyle(
  202. "r",
  203. ) + mutedStyle(
  204. " restore",
  205. )
  206. bgColor := t.BackgroundPanel()
  207. helpView := styles.NewStyle().
  208. Background(bgColor).
  209. Width(layout.Current.Container.Width - 14).
  210. PaddingLeft(1).
  211. PaddingTop(1).
  212. Render(helpText)
  213. content := strings.Join([]string{listView, helpView}, "\n")
  214. return n.modal.Render(content, background)
  215. }
  216. func (n *timelineDialog) Close() tea.Cmd {
  217. return nil
  218. }
  219. // extractMessagePreview extracts a preview from message parts
  220. func extractMessagePreview(parts []opencode.PartUnion) string {
  221. for _, part := range parts {
  222. switch casted := part.(type) {
  223. case opencode.TextPart:
  224. text := strings.TrimSpace(casted.Text)
  225. if text != "" {
  226. return text
  227. }
  228. }
  229. }
  230. return "No text content"
  231. }
  232. // countToolsInResponse counts tools in the assistant's response to a user message
  233. func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
  234. count := 0
  235. // Look at subsequent messages to find the assistant's response
  236. for i := userMessageIndex + 1; i < len(messages); i++ {
  237. message := messages[i]
  238. // If we hit another user message, stop looking
  239. if _, isUser := message.Info.(opencode.UserMessage); isUser {
  240. break
  241. }
  242. // Count tools in this assistant message
  243. for _, part := range message.Parts {
  244. switch part.(type) {
  245. case opencode.ToolPart:
  246. count++
  247. }
  248. }
  249. }
  250. return count
  251. }
  252. // NewTimelineDialog creates a new session timeline dialog
  253. func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
  254. var items []timelineItem
  255. // Filter to only user messages and extract relevant info
  256. for i, message := range app.Messages {
  257. if userMsg, ok := message.Info.(opencode.UserMessage); ok {
  258. preview := extractMessagePreview(message.Parts)
  259. toolCount := countToolsInResponse(app.Messages, i)
  260. items = append(items, timelineItem{
  261. messageID: userMsg.ID,
  262. content: preview,
  263. timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
  264. index: i,
  265. toolCount: toolCount,
  266. })
  267. }
  268. }
  269. listComponent := list.NewListComponent(
  270. list.WithItems(items),
  271. list.WithMaxVisibleHeight[timelineItem](12),
  272. list.WithFallbackMessage[timelineItem]("No user messages in this session"),
  273. list.WithAlphaNumericKeys[timelineItem](true),
  274. list.WithRenderFunc(
  275. func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
  276. // Determine if this item is the current message for the session
  277. isCurrent := false
  278. if app.Session.Revert.MessageID != "" {
  279. // When reverted, Session.Revert.MessageID contains the NEXT user message ID
  280. // So we need to find the previous user message to highlight the correct one
  281. for i, navItem := range items {
  282. if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
  283. // Found the next message, so the previous one is current
  284. isCurrent = item.messageID == items[i-1].messageID
  285. break
  286. }
  287. }
  288. } else if len(app.Messages) > 0 {
  289. // If not reverted, highlight the last user message
  290. lastUserMsgID := ""
  291. for i := len(app.Messages) - 1; i >= 0; i-- {
  292. if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
  293. lastUserMsgID = userMsg.ID
  294. break
  295. }
  296. }
  297. isCurrent = item.messageID == lastUserMsgID
  298. }
  299. // Only show the dot if undo/redo/restore is available
  300. showDot := app.Session.Revert.MessageID != ""
  301. return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
  302. },
  303. ),
  304. list.WithSelectableFunc(func(item timelineItem) bool {
  305. return true
  306. }),
  307. )
  308. listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
  309. return &timelineDialog{
  310. list: listComponent,
  311. app: app,
  312. modal: modal.New(
  313. modal.WithTitle("Session Timeline"),
  314. modal.WithMaxWidth(layout.Current.Container.Width-8),
  315. ),
  316. }
  317. }