navigation.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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. // NavigationDialog interface for the session navigation dialog
  19. type NavigationDialog 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. // navigationItem represents a user message in the navigation list
  32. type navigationItem 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 navigationItem) Render(
  40. selected bool,
  41. width int,
  42. isFirstInViewport bool,
  43. baseStyle styles.Style,
  44. ) string {
  45. t := theme.CurrentTheme()
  46. infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
  47. textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
  48. // Format timestamp - only apply color when not selected
  49. var timeStr string
  50. var timeVisualLen int
  51. if selected {
  52. timeStr = n.timestamp.Format("15:04") + " "
  53. timeVisualLen = lipgloss.Width(timeStr)
  54. } else {
  55. timeStr = infoStyle(n.timestamp.Format("15:04") + " ")
  56. timeVisualLen = lipgloss.Width(timeStr)
  57. }
  58. // Tool count display (fixed width for alignment) - only apply color when not selected
  59. toolInfo := ""
  60. toolInfoVisualLen := 0
  61. if n.toolCount > 0 {
  62. toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
  63. if selected {
  64. toolInfo = toolInfoText
  65. } else {
  66. toolInfo = infoStyle(toolInfoText)
  67. }
  68. toolInfoVisualLen = lipgloss.Width(toolInfo)
  69. }
  70. // Calculate available space for content
  71. // Reserve space for: timestamp + space + toolInfo + padding + some buffer
  72. reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
  73. contentWidth := max(width-reservedSpace, 8)
  74. truncatedContent := truncate.StringWithTail(
  75. strings.Split(n.content, "\n")[0],
  76. uint(contentWidth),
  77. "...",
  78. )
  79. // Apply normal text color to content for non-selected items
  80. var styledContent string
  81. if selected {
  82. styledContent = truncatedContent
  83. } else {
  84. styledContent = textStyle(truncatedContent)
  85. }
  86. // Create the line with proper spacing - content left-aligned, tools right-aligned
  87. var text string
  88. text = timeStr + styledContent
  89. if toolInfo != "" {
  90. bgColor := t.BackgroundPanel()
  91. if selected {
  92. bgColor = t.Primary()
  93. }
  94. text = layout.Render(
  95. layout.FlexOptions{
  96. Background: &bgColor,
  97. Direction: layout.Row,
  98. Justify: layout.JustifySpaceBetween,
  99. Align: layout.AlignStretch,
  100. Width: width - 2,
  101. },
  102. layout.FlexItem{
  103. View: text,
  104. },
  105. layout.FlexItem{
  106. View: toolInfo,
  107. },
  108. )
  109. }
  110. var itemStyle styles.Style
  111. if selected {
  112. itemStyle = baseStyle.
  113. Background(t.Primary()).
  114. Foreground(t.BackgroundElement()).
  115. Width(width).
  116. PaddingLeft(1)
  117. } else {
  118. itemStyle = baseStyle.PaddingLeft(1)
  119. }
  120. return itemStyle.Render(text)
  121. }
  122. func (n navigationItem) Selectable() bool {
  123. return true
  124. }
  125. type navigationDialog struct {
  126. width int
  127. height int
  128. modal *modal.Modal
  129. list list.List[navigationItem]
  130. app *app.App
  131. }
  132. func (n *navigationDialog) Init() tea.Cmd {
  133. return nil
  134. }
  135. func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
  136. switch msg := msg.(type) {
  137. case tea.WindowSizeMsg:
  138. n.width = msg.Width
  139. n.height = msg.Height
  140. n.list.SetMaxWidth(layout.Current.Container.Width - 12)
  141. case tea.KeyPressMsg:
  142. switch msg.String() {
  143. case "up", "down":
  144. // Handle navigation and immediately scroll to selected message
  145. var cmd tea.Cmd
  146. listModel, cmd := n.list.Update(msg)
  147. n.list = listModel.(list.List[navigationItem])
  148. // Get the newly selected item and scroll to it immediately
  149. if item, idx := n.list.GetSelectedItem(); idx >= 0 {
  150. return n, tea.Sequence(
  151. cmd,
  152. util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
  153. )
  154. }
  155. return n, cmd
  156. case "r":
  157. // Restore conversation to selected message
  158. if item, idx := n.list.GetSelectedItem(); idx >= 0 {
  159. return n, tea.Sequence(
  160. util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
  161. util.CmdHandler(modal.CloseModalMsg{}),
  162. )
  163. }
  164. case "enter":
  165. // Keep Enter functionality for closing the modal
  166. if _, idx := n.list.GetSelectedItem(); idx >= 0 {
  167. return n, util.CmdHandler(modal.CloseModalMsg{})
  168. }
  169. }
  170. }
  171. var cmd tea.Cmd
  172. listModel, cmd := n.list.Update(msg)
  173. n.list = listModel.(list.List[navigationItem])
  174. return n, cmd
  175. }
  176. func (n *navigationDialog) Render(background string) string {
  177. listView := n.list.View()
  178. t := theme.CurrentTheme()
  179. keyStyle := styles.NewStyle().
  180. Foreground(t.Text()).
  181. Background(t.BackgroundPanel()).
  182. Bold(true).
  183. Render
  184. mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
  185. helpText := keyStyle(
  186. "↑/↓",
  187. ) + mutedStyle(
  188. " jump ",
  189. ) + keyStyle(
  190. "r",
  191. ) + mutedStyle(
  192. " restore",
  193. )
  194. bgColor := t.BackgroundPanel()
  195. helpView := styles.NewStyle().
  196. Background(bgColor).
  197. Width(layout.Current.Container.Width - 14).
  198. PaddingLeft(1).
  199. PaddingTop(1).
  200. Render(helpText)
  201. content := strings.Join([]string{listView, helpView}, "\n")
  202. return n.modal.Render(content, background)
  203. }
  204. func (n *navigationDialog) Close() tea.Cmd {
  205. return nil
  206. }
  207. // extractMessagePreview extracts a preview from message parts
  208. func extractMessagePreview(parts []opencode.PartUnion) string {
  209. for _, part := range parts {
  210. switch casted := part.(type) {
  211. case opencode.TextPart:
  212. text := strings.TrimSpace(casted.Text)
  213. if text != "" {
  214. return text
  215. }
  216. }
  217. }
  218. return "No text content"
  219. }
  220. // countToolsInResponse counts tools in the assistant's response to a user message
  221. func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
  222. count := 0
  223. // Look at subsequent messages to find the assistant's response
  224. for i := userMessageIndex + 1; i < len(messages); i++ {
  225. message := messages[i]
  226. // If we hit another user message, stop looking
  227. if _, isUser := message.Info.(opencode.UserMessage); isUser {
  228. break
  229. }
  230. // Count tools in this assistant message
  231. for _, part := range message.Parts {
  232. switch part.(type) {
  233. case opencode.ToolPart:
  234. count++
  235. }
  236. }
  237. }
  238. return count
  239. }
  240. // NewNavigationDialog creates a new session navigation dialog
  241. func NewNavigationDialog(app *app.App) NavigationDialog {
  242. var items []navigationItem
  243. // Filter to only user messages and extract relevant info
  244. for i, message := range app.Messages {
  245. if userMsg, ok := message.Info.(opencode.UserMessage); ok {
  246. preview := extractMessagePreview(message.Parts)
  247. toolCount := countToolsInResponse(app.Messages, i)
  248. items = append(items, navigationItem{
  249. messageID: userMsg.ID,
  250. content: preview,
  251. timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
  252. index: i,
  253. toolCount: toolCount,
  254. })
  255. }
  256. }
  257. listComponent := list.NewListComponent(
  258. list.WithItems(items),
  259. list.WithMaxVisibleHeight[navigationItem](12),
  260. list.WithFallbackMessage[navigationItem]("No user messages in this session"),
  261. list.WithAlphaNumericKeys[navigationItem](true),
  262. list.WithRenderFunc(
  263. func(item navigationItem, selected bool, width int, baseStyle styles.Style) string {
  264. return item.Render(selected, width, false, baseStyle)
  265. },
  266. ),
  267. list.WithSelectableFunc(func(item navigationItem) bool {
  268. return true
  269. }),
  270. )
  271. listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
  272. return &navigationDialog{
  273. list: listComponent,
  274. app: app,
  275. modal: modal.New(
  276. modal.WithTitle("Jump to Message"),
  277. modal.WithMaxWidth(layout.Current.Container.Width-8),
  278. ),
  279. }
  280. }