|
|
@@ -0,0 +1,294 @@
|
|
|
+package dialog
|
|
|
+
|
|
|
+import (
|
|
|
+ "fmt"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+
|
|
|
+ tea "github.com/charmbracelet/bubbletea/v2"
|
|
|
+ "github.com/muesli/reflow/truncate"
|
|
|
+ "github.com/sst/opencode-sdk-go"
|
|
|
+ "github.com/sst/opencode/internal/app"
|
|
|
+ "github.com/sst/opencode/internal/components/list"
|
|
|
+ "github.com/sst/opencode/internal/components/modal"
|
|
|
+ "github.com/sst/opencode/internal/layout"
|
|
|
+ "github.com/sst/opencode/internal/styles"
|
|
|
+ "github.com/sst/opencode/internal/theme"
|
|
|
+ "github.com/sst/opencode/internal/util"
|
|
|
+)
|
|
|
+
|
|
|
+// NavigationDialog interface for the session navigation dialog
|
|
|
+type NavigationDialog interface {
|
|
|
+ layout.Modal
|
|
|
+}
|
|
|
+
|
|
|
+// ScrollToMessageMsg is sent when a message should be scrolled to
|
|
|
+type ScrollToMessageMsg struct {
|
|
|
+ MessageID string
|
|
|
+}
|
|
|
+
|
|
|
+// RestoreToMessageMsg is sent when conversation should be restored to a specific message
|
|
|
+type RestoreToMessageMsg struct {
|
|
|
+ MessageID string
|
|
|
+ Index int
|
|
|
+}
|
|
|
+
|
|
|
+// navigationItem represents a user message in the navigation list
|
|
|
+type navigationItem struct {
|
|
|
+ messageID string
|
|
|
+ content string
|
|
|
+ timestamp time.Time
|
|
|
+ index int // Index in the full message list
|
|
|
+ toolCount int // Number of tools used in this message
|
|
|
+}
|
|
|
+
|
|
|
+func (n navigationItem) Render(
|
|
|
+ selected bool,
|
|
|
+ width int,
|
|
|
+ isFirstInViewport bool,
|
|
|
+ baseStyle styles.Style,
|
|
|
+) string {
|
|
|
+ t := theme.CurrentTheme()
|
|
|
+
|
|
|
+ // Format timestamp - only apply color when not selected
|
|
|
+ var timeStr string
|
|
|
+ var timeVisualLen int
|
|
|
+ if selected {
|
|
|
+ timeStr = n.timestamp.Format("15:04")
|
|
|
+ timeVisualLen = len(timeStr)
|
|
|
+ } else {
|
|
|
+ infoStyle := styles.NewStyle().Foreground(t.Info()).Render
|
|
|
+ timeStr = infoStyle(n.timestamp.Format("15:04"))
|
|
|
+ timeVisualLen = len(n.timestamp.Format("15:04")) // Visual length without color codes
|
|
|
+ }
|
|
|
+
|
|
|
+ // Tool count display (fixed width for alignment) - only apply color when not selected
|
|
|
+ toolInfo := ""
|
|
|
+ toolInfoVisualLen := 0
|
|
|
+ if n.toolCount > 0 {
|
|
|
+ toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount)
|
|
|
+ if selected {
|
|
|
+ toolInfo = toolInfoText
|
|
|
+ } else {
|
|
|
+ infoStyle := styles.NewStyle().Foreground(t.Info()).Render
|
|
|
+ toolInfo = infoStyle(toolInfoText)
|
|
|
+ }
|
|
|
+ toolInfoVisualLen = len(toolInfoText) // Use the visual length, not the styled length
|
|
|
+ }
|
|
|
+
|
|
|
+ // Calculate available space for content
|
|
|
+ // Reserve space for: timestamp + space + toolInfo + padding + some buffer
|
|
|
+ reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
|
|
|
+ contentWidth := width - reservedSpace
|
|
|
+ if contentWidth < 8 {
|
|
|
+ contentWidth = 8
|
|
|
+ }
|
|
|
+
|
|
|
+ truncatedContent := truncate.StringWithTail(n.content, uint(contentWidth), "...")
|
|
|
+
|
|
|
+ // Apply normal text color to content for non-selected items
|
|
|
+ var styledContent string
|
|
|
+ if selected {
|
|
|
+ styledContent = truncatedContent
|
|
|
+ } else {
|
|
|
+ textStyle := styles.NewStyle().Foreground(t.Text()).Render
|
|
|
+ styledContent = textStyle(truncatedContent)
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create the line with proper spacing - content left-aligned, tools right-aligned
|
|
|
+ var text string
|
|
|
+ if toolInfo != "" {
|
|
|
+ // Calculate spacing to right-align the tool count
|
|
|
+ contentPart := fmt.Sprintf("%s %s", timeStr, styledContent)
|
|
|
+ totalContentLen := timeVisualLen + 1 + len(truncatedContent) // Use visual length for content
|
|
|
+ availableWidth := width - 2 // Account for padding
|
|
|
+ spacingNeeded := availableWidth - totalContentLen - toolInfoVisualLen
|
|
|
+ if spacingNeeded < 1 {
|
|
|
+ spacingNeeded = 1
|
|
|
+ }
|
|
|
+ text = fmt.Sprintf("%s%s%s", contentPart, strings.Repeat(" ", spacingNeeded), toolInfo)
|
|
|
+ } else {
|
|
|
+ text = fmt.Sprintf("%s %s", timeStr, styledContent)
|
|
|
+ }
|
|
|
+
|
|
|
+ var itemStyle styles.Style
|
|
|
+ if selected {
|
|
|
+ itemStyle = baseStyle.
|
|
|
+ Background(t.Primary()).
|
|
|
+ Foreground(t.BackgroundElement()).
|
|
|
+ Width(width).
|
|
|
+ PaddingLeft(1)
|
|
|
+ } else {
|
|
|
+ itemStyle = baseStyle.
|
|
|
+ PaddingLeft(1)
|
|
|
+ }
|
|
|
+
|
|
|
+ return itemStyle.Render(text)
|
|
|
+}
|
|
|
+
|
|
|
+func (n navigationItem) Selectable() bool {
|
|
|
+ return true
|
|
|
+}
|
|
|
+
|
|
|
+type navigationDialog struct {
|
|
|
+ width int
|
|
|
+ height int
|
|
|
+ modal *modal.Modal
|
|
|
+ list list.List[navigationItem]
|
|
|
+ app *app.App
|
|
|
+}
|
|
|
+
|
|
|
+func (n *navigationDialog) Init() tea.Cmd {
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
|
+ switch msg := msg.(type) {
|
|
|
+ case tea.WindowSizeMsg:
|
|
|
+ n.width = msg.Width
|
|
|
+ n.height = msg.Height
|
|
|
+ n.list.SetMaxWidth(layout.Current.Container.Width - 12)
|
|
|
+ case tea.KeyPressMsg:
|
|
|
+ switch msg.String() {
|
|
|
+ case "up", "down":
|
|
|
+ // Handle navigation and immediately scroll to selected message
|
|
|
+ var cmd tea.Cmd
|
|
|
+ listModel, cmd := n.list.Update(msg)
|
|
|
+ n.list = listModel.(list.List[navigationItem])
|
|
|
+
|
|
|
+ // Get the newly selected item and scroll to it immediately
|
|
|
+ if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
|
|
+ return n, tea.Sequence(
|
|
|
+ cmd,
|
|
|
+ util.CmdHandler(ScrollToMessageMsg{MessageID: item.messageID}),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ return n, cmd
|
|
|
+ case "r":
|
|
|
+ // Restore conversation to selected message
|
|
|
+ if item, idx := n.list.GetSelectedItem(); idx >= 0 {
|
|
|
+ return n, tea.Sequence(
|
|
|
+ util.CmdHandler(RestoreToMessageMsg{MessageID: item.messageID, Index: item.index}),
|
|
|
+ util.CmdHandler(modal.CloseModalMsg{}),
|
|
|
+ )
|
|
|
+ }
|
|
|
+ case "enter":
|
|
|
+ // Keep Enter functionality for closing the modal
|
|
|
+ if _, idx := n.list.GetSelectedItem(); idx >= 0 {
|
|
|
+ return n, util.CmdHandler(modal.CloseModalMsg{})
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var cmd tea.Cmd
|
|
|
+ listModel, cmd := n.list.Update(msg)
|
|
|
+ n.list = listModel.(list.List[navigationItem])
|
|
|
+ return n, cmd
|
|
|
+}
|
|
|
+
|
|
|
+func (n *navigationDialog) Render(background string) string {
|
|
|
+ listView := n.list.View()
|
|
|
+
|
|
|
+ t := theme.CurrentTheme()
|
|
|
+ keyStyle := styles.NewStyle().Foreground(t.Warning()).Background(t.BackgroundPanel()).Render
|
|
|
+ mutedStyle := styles.NewStyle().Foreground(t.TextMuted()).Background(t.BackgroundPanel()).Render
|
|
|
+
|
|
|
+ helpText := keyStyle("↑/↓") + mutedStyle(" jump") + " " + keyStyle("r") + mutedStyle(" restore")
|
|
|
+
|
|
|
+ bgColor := t.BackgroundPanel()
|
|
|
+ helpView := styles.NewStyle().
|
|
|
+ Background(bgColor).
|
|
|
+ Width(layout.Current.Container.Width - 14).
|
|
|
+ PaddingLeft(1).
|
|
|
+ PaddingTop(1).
|
|
|
+ Render(helpText)
|
|
|
+
|
|
|
+ content := strings.Join([]string{listView, helpView}, "\n")
|
|
|
+
|
|
|
+ return n.modal.Render(content, background)
|
|
|
+}
|
|
|
+
|
|
|
+func (n *navigationDialog) Close() tea.Cmd {
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+// extractMessagePreview extracts a preview from message parts
|
|
|
+func extractMessagePreview(parts []opencode.PartUnion) string {
|
|
|
+ for _, part := range parts {
|
|
|
+ switch casted := part.(type) {
|
|
|
+ case opencode.TextPart:
|
|
|
+ text := strings.TrimSpace(casted.Text)
|
|
|
+ if text != "" {
|
|
|
+ return text
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return "No text content"
|
|
|
+}
|
|
|
+
|
|
|
+// countToolsInResponse counts tools in the assistant's response to a user message
|
|
|
+func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
|
|
|
+ count := 0
|
|
|
+ // Look at subsequent messages to find the assistant's response
|
|
|
+ for i := userMessageIndex + 1; i < len(messages); i++ {
|
|
|
+ message := messages[i]
|
|
|
+ // If we hit another user message, stop looking
|
|
|
+ if _, isUser := message.Info.(opencode.UserMessage); isUser {
|
|
|
+ break
|
|
|
+ }
|
|
|
+ // Count tools in this assistant message
|
|
|
+ for _, part := range message.Parts {
|
|
|
+ switch part.(type) {
|
|
|
+ case opencode.ToolPart:
|
|
|
+ count++
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return count
|
|
|
+}
|
|
|
+
|
|
|
+// NewNavigationDialog creates a new session navigation dialog
|
|
|
+func NewNavigationDialog(app *app.App) NavigationDialog {
|
|
|
+ var items []navigationItem
|
|
|
+
|
|
|
+ // Filter to only user messages and extract relevant info
|
|
|
+ for i, message := range app.Messages {
|
|
|
+ if userMsg, ok := message.Info.(opencode.UserMessage); ok {
|
|
|
+ preview := extractMessagePreview(message.Parts)
|
|
|
+ toolCount := countToolsInResponse(app.Messages, i)
|
|
|
+
|
|
|
+ items = append(items, navigationItem{
|
|
|
+ messageID: userMsg.ID,
|
|
|
+ content: preview,
|
|
|
+ timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
|
|
|
+ index: i,
|
|
|
+ toolCount: toolCount,
|
|
|
+ })
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ listComponent := list.NewListComponent(
|
|
|
+ list.WithItems(items),
|
|
|
+ list.WithMaxVisibleHeight[navigationItem](12),
|
|
|
+ list.WithFallbackMessage[navigationItem]("No user messages in this session"),
|
|
|
+ list.WithAlphaNumericKeys[navigationItem](true),
|
|
|
+ list.WithRenderFunc(
|
|
|
+ func(item navigationItem, selected bool, width int, baseStyle styles.Style) string {
|
|
|
+ return item.Render(selected, width, false, baseStyle)
|
|
|
+ },
|
|
|
+ ),
|
|
|
+ list.WithSelectableFunc(func(item navigationItem) bool {
|
|
|
+ return true
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
|
|
|
+
|
|
|
+ return &navigationDialog{
|
|
|
+ list: listComponent,
|
|
|
+ app: app,
|
|
|
+ modal: modal.New(
|
|
|
+ modal.WithTitle("Jump to Message"),
|
|
|
+ modal.WithMaxWidth(layout.Current.Container.Width-8),
|
|
|
+ ),
|
|
|
+ }
|
|
|
+}
|