Procházet zdrojové kódy

tweak(timeline): add a dot to the session timeline modal for better visual cue of session's revert point (#1978)

spoons-and-mirrors před 6 měsíci
rodič
revize
cd3d91209a

+ 1 - 0
packages/opencode/src/config/config.ts

@@ -229,6 +229,7 @@ export namespace Config {
       session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
       session_export: z.string().optional().default("<leader>x").describe("Export session to editor"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_new: z.string().optional().default("<leader>n").describe("Create a new session"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
       session_list: z.string().optional().default("<leader>l").describe("List all sessions"),
+      session_timeline: z.string().optional().default("<leader>g").describe("Show session timeline"),
       session_share: z.string().optional().default("<leader>s").describe("Share current session"),
       session_share: z.string().optional().default("<leader>s").describe("Share current session"),
       session_unshare: z.string().optional().default("none").describe("Unshare current session"),
       session_unshare: z.string().optional().default("none").describe("Unshare current session"),
       session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),
       session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"),

+ 35 - 36
packages/tui/internal/commands/command.go

@@ -107,7 +107,6 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
 }
 }
 
 
 const (
 const (
-
 	SessionChildCycleCommand        CommandName = "session_child_cycle"
 	SessionChildCycleCommand        CommandName = "session_child_cycle"
 	SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
 	SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
 	ModelCycleRecentReverseCommand  CommandName = "model_cycle_recent_reverse"
 	ModelCycleRecentReverseCommand  CommandName = "model_cycle_recent_reverse"
@@ -119,40 +118,40 @@ const (
 	EditorOpenCommand               CommandName = "editor_open"
 	EditorOpenCommand               CommandName = "editor_open"
 	SessionNewCommand               CommandName = "session_new"
 	SessionNewCommand               CommandName = "session_new"
 	SessionListCommand              CommandName = "session_list"
 	SessionListCommand              CommandName = "session_list"
-	SessionNavigationCommand        CommandName = "session_navigation"
+	SessionTimelineCommand          CommandName = "session_timeline"
 	SessionShareCommand             CommandName = "session_share"
 	SessionShareCommand             CommandName = "session_share"
 	SessionUnshareCommand           CommandName = "session_unshare"
 	SessionUnshareCommand           CommandName = "session_unshare"
-	SessionInterruptCommand  	      CommandName = "session_interrupt"
-	SessionCompactCommand     	    CommandName = "session_compact"
-	SessionExportCommand        	  CommandName = "session_export"
-	ToolDetailsCommand            	CommandName = "tool_details"
-	ThinkingBlocksCommand         	CommandName = "thinking_blocks"
-	ModelListCommand              	CommandName = "model_list"
-	AgentListCommand              	CommandName = "agent_list"
-	ModelCycleRecentCommand       	CommandName = "model_cycle_recent"
-	ThemeListCommand              	CommandName = "theme_list"
-	FileListCommand               	CommandName = "file_list"
-	FileCloseCommand              	CommandName = "file_close"
-	FileSearchCommand             	CommandName = "file_search"
-	FileDiffToggleCommand         	CommandName = "file_diff_toggle"
-	ProjectInitCommand            	CommandName = "project_init"
-	InputClearCommand             	CommandName = "input_clear"
-	InputPasteCommand             	CommandName = "input_paste"
-	InputSubmitCommand            	CommandName = "input_submit"
-	InputNewlineCommand           	CommandName = "input_newline"
-	MessagesPageUpCommand        		CommandName = "messages_page_up"
-	MessagesPageDownCommand      		CommandName = "messages_page_down"
-	MessagesHalfPageUpCommand    		CommandName = "messages_half_page_up"
-	MessagesHalfPageDownCommand  		CommandName = "messages_half_page_down"
-	MessagesPreviousCommand       	CommandName = "messages_previous"
-	MessagesNextCommand           	CommandName = "messages_next"
-	MessagesFirstCommand          	CommandName = "messages_first"
-	MessagesLastCommand           	CommandName = "messages_last"
-	MessagesLayoutToggleCommand   	CommandName = "messages_layout_toggle"
-	MessagesCopyCommand           	CommandName = "messages_copy"
-	MessagesUndoCommand           	CommandName = "messages_undo"
-	MessagesRedoCommand           	CommandName = "messages_redo"
-	AppExitCommand                	CommandName = "app_exit"
+	SessionInterruptCommand         CommandName = "session_interrupt"
+	SessionCompactCommand           CommandName = "session_compact"
+	SessionExportCommand            CommandName = "session_export"
+	ToolDetailsCommand              CommandName = "tool_details"
+	ThinkingBlocksCommand           CommandName = "thinking_blocks"
+	ModelListCommand                CommandName = "model_list"
+	AgentListCommand                CommandName = "agent_list"
+	ModelCycleRecentCommand         CommandName = "model_cycle_recent"
+	ThemeListCommand                CommandName = "theme_list"
+	FileListCommand                 CommandName = "file_list"
+	FileCloseCommand                CommandName = "file_close"
+	FileSearchCommand               CommandName = "file_search"
+	FileDiffToggleCommand           CommandName = "file_diff_toggle"
+	ProjectInitCommand              CommandName = "project_init"
+	InputClearCommand               CommandName = "input_clear"
+	InputPasteCommand               CommandName = "input_paste"
+	InputSubmitCommand              CommandName = "input_submit"
+	InputNewlineCommand             CommandName = "input_newline"
+	MessagesPageUpCommand           CommandName = "messages_page_up"
+	MessagesPageDownCommand         CommandName = "messages_page_down"
+	MessagesHalfPageUpCommand       CommandName = "messages_half_page_up"
+	MessagesHalfPageDownCommand     CommandName = "messages_half_page_down"
+	MessagesPreviousCommand         CommandName = "messages_previous"
+	MessagesNextCommand             CommandName = "messages_next"
+	MessagesFirstCommand            CommandName = "messages_first"
+	MessagesLastCommand             CommandName = "messages_last"
+	MessagesLayoutToggleCommand     CommandName = "messages_layout_toggle"
+	MessagesCopyCommand             CommandName = "messages_copy"
+	MessagesUndoCommand             CommandName = "messages_undo"
+	MessagesRedoCommand             CommandName = "messages_redo"
+	AppExitCommand                  CommandName = "app_exit"
 )
 )
 
 
 func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
 func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
@@ -216,10 +215,10 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
 			Trigger:     []string{"sessions", "resume", "continue"},
 			Trigger:     []string{"sessions", "resume", "continue"},
 		},
 		},
 		{
 		{
-			Name:        SessionNavigationCommand,
-			Description: "jump to message",
+			Name:        SessionTimelineCommand,
+			Description: "show session timeline",
 			Keybindings: parseBindings("<leader>g"),
 			Keybindings: parseBindings("<leader>g"),
-			Trigger:     []string{"jump", "goto", "navigate"},
+			Trigger:     []string{"timeline", "history", "goto"},
 		},
 		},
 		{
 		{
 			Name:        SessionShareCommand,
 			Name:        SessionShareCommand,

+ 69 - 31
packages/tui/internal/components/dialog/navigation.go → packages/tui/internal/components/dialog/timeline.go

@@ -18,8 +18,8 @@ import (
 	"github.com/sst/opencode/internal/util"
 	"github.com/sst/opencode/internal/util"
 )
 )
 
 
-// NavigationDialog interface for the session navigation dialog
-type NavigationDialog interface {
+// TimelineDialog interface for the session timeline dialog
+type TimelineDialog interface {
 	layout.Modal
 	layout.Modal
 }
 }
 
 
@@ -34,8 +34,8 @@ type RestoreToMessageMsg struct {
 	Index     int
 	Index     int
 }
 }
 
 
-// navigationItem represents a user message in the navigation list
-type navigationItem struct {
+// timelineItem represents a user message in the timeline list
+type timelineItem struct {
 	messageID string
 	messageID string
 	content   string
 	content   string
 	timestamp time.Time
 	timestamp time.Time
@@ -43,25 +43,38 @@ type navigationItem struct {
 	toolCount int // Number of tools used in this message
 	toolCount int // Number of tools used in this message
 }
 }
 
 
-func (n navigationItem) Render(
+func (n timelineItem) Render(
 	selected bool,
 	selected bool,
 	width int,
 	width int,
 	isFirstInViewport bool,
 	isFirstInViewport bool,
 	baseStyle styles.Style,
 	baseStyle styles.Style,
+	isCurrent bool,
 ) string {
 ) string {
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
 	infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
 	infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render
 	textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
 	textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render
 
 
+	// Add dot after timestamp if this is the current message - only apply color when not selected
+	var dot string
+	var dotVisualLen int
+	if isCurrent {
+		if selected {
+			dot = "● "
+		} else {
+			dot = lipgloss.NewStyle().Foreground(t.Success()).Render("● ")
+		}
+		dotVisualLen = 2 // "● " is 2 characters wide
+	}
+
 	// Format timestamp - only apply color when not selected
 	// Format timestamp - only apply color when not selected
 	var timeStr string
 	var timeStr string
 	var timeVisualLen int
 	var timeVisualLen int
 	if selected {
 	if selected {
-		timeStr = n.timestamp.Format("15:04") + " "
-		timeVisualLen = lipgloss.Width(timeStr)
+		timeStr = n.timestamp.Format("15:04") + " " + dot
+		timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
 	} else {
 	} else {
-		timeStr = infoStyle(n.timestamp.Format("15:04") + " ")
-		timeVisualLen = lipgloss.Width(timeStr)
+		timeStr = infoStyle(n.timestamp.Format("15:04")+" ") + dot
+		timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen
 	}
 	}
 
 
 	// Tool count display (fixed width for alignment) - only apply color when not selected
 	// Tool count display (fixed width for alignment) - only apply color when not selected
@@ -78,7 +91,7 @@ func (n navigationItem) Render(
 	}
 	}
 
 
 	// Calculate available space for content
 	// Calculate available space for content
-	// Reserve space for: timestamp + space + toolInfo + padding + some buffer
+	// Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer
 	reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
 	reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4
 	contentWidth := max(width-reservedSpace, 8)
 	contentWidth := max(width-reservedSpace, 8)
 
 
@@ -135,23 +148,23 @@ func (n navigationItem) Render(
 	return itemStyle.Render(text)
 	return itemStyle.Render(text)
 }
 }
 
 
-func (n navigationItem) Selectable() bool {
+func (n timelineItem) Selectable() bool {
 	return true
 	return true
 }
 }
 
 
-type navigationDialog struct {
+type timelineDialog struct {
 	width  int
 	width  int
 	height int
 	height int
 	modal  *modal.Modal
 	modal  *modal.Modal
-	list   list.List[navigationItem]
+	list   list.List[timelineItem]
 	app    *app.App
 	app    *app.App
 }
 }
 
 
-func (n *navigationDialog) Init() tea.Cmd {
+func (n *timelineDialog) Init() tea.Cmd {
 	return nil
 	return nil
 }
 }
 
 
-func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+func (n *timelineDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 	switch msg := msg.(type) {
 	switch msg := msg.(type) {
 	case tea.WindowSizeMsg:
 	case tea.WindowSizeMsg:
 		n.width = msg.Width
 		n.width = msg.Width
@@ -163,7 +176,7 @@ func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 			// Handle navigation and immediately scroll to selected message
 			// Handle navigation and immediately scroll to selected message
 			var cmd tea.Cmd
 			var cmd tea.Cmd
 			listModel, cmd := n.list.Update(msg)
 			listModel, cmd := n.list.Update(msg)
-			n.list = listModel.(list.List[navigationItem])
+			n.list = listModel.(list.List[timelineItem])
 
 
 			// Get the newly selected item and scroll to it immediately
 			// Get the newly selected item and scroll to it immediately
 			if item, idx := n.list.GetSelectedItem(); idx >= 0 {
 			if item, idx := n.list.GetSelectedItem(); idx >= 0 {
@@ -191,11 +204,11 @@ func (n *navigationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 
 
 	var cmd tea.Cmd
 	var cmd tea.Cmd
 	listModel, cmd := n.list.Update(msg)
 	listModel, cmd := n.list.Update(msg)
-	n.list = listModel.(list.List[navigationItem])
+	n.list = listModel.(list.List[timelineItem])
 	return n, cmd
 	return n, cmd
 }
 }
 
 
-func (n *navigationDialog) Render(background string) string {
+func (n *timelineDialog) Render(background string) string {
 	listView := n.list.View()
 	listView := n.list.View()
 
 
 	t := theme.CurrentTheme()
 	t := theme.CurrentTheme()
@@ -229,7 +242,7 @@ func (n *navigationDialog) Render(background string) string {
 	return n.modal.Render(content, background)
 	return n.modal.Render(content, background)
 }
 }
 
 
-func (n *navigationDialog) Close() tea.Cmd {
+func (n *timelineDialog) Close() tea.Cmd {
 	return nil
 	return nil
 }
 }
 
 
@@ -268,9 +281,9 @@ func countToolsInResponse(messages []app.Message, userMessageIndex int) int {
 	return count
 	return count
 }
 }
 
 
-// NewNavigationDialog creates a new session navigation dialog
-func NewNavigationDialog(app *app.App) NavigationDialog {
-	var items []navigationItem
+// NewTimelineDialog creates a new session timeline dialog
+func NewTimelineDialog(app *app.App) TimelineDialog { // renamed from NewNavigationDialog
+	var items []timelineItem
 
 
 	// Filter to only user messages and extract relevant info
 	// Filter to only user messages and extract relevant info
 	for i, message := range app.Messages {
 	for i, message := range app.Messages {
@@ -278,7 +291,7 @@ func NewNavigationDialog(app *app.App) NavigationDialog {
 			preview := extractMessagePreview(message.Parts)
 			preview := extractMessagePreview(message.Parts)
 			toolCount := countToolsInResponse(app.Messages, i)
 			toolCount := countToolsInResponse(app.Messages, i)
 
 
-			items = append(items, navigationItem{
+			items = append(items, timelineItem{
 				messageID: userMsg.ID,
 				messageID: userMsg.ID,
 				content:   preview,
 				content:   preview,
 				timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
 				timestamp: time.UnixMilli(int64(userMsg.Time.Created)),
@@ -290,25 +303,50 @@ func NewNavigationDialog(app *app.App) NavigationDialog {
 
 
 	listComponent := list.NewListComponent(
 	listComponent := list.NewListComponent(
 		list.WithItems(items),
 		list.WithItems(items),
-		list.WithMaxVisibleHeight[navigationItem](12),
-		list.WithFallbackMessage[navigationItem]("No user messages in this session"),
-		list.WithAlphaNumericKeys[navigationItem](true),
+		list.WithMaxVisibleHeight[timelineItem](12),
+		list.WithFallbackMessage[timelineItem]("No user messages in this session"),
+		list.WithAlphaNumericKeys[timelineItem](true),
 		list.WithRenderFunc(
 		list.WithRenderFunc(
-			func(item navigationItem, selected bool, width int, baseStyle styles.Style) string {
-				return item.Render(selected, width, false, baseStyle)
+			func(item timelineItem, selected bool, width int, baseStyle styles.Style) string {
+				// Determine if this item is the current message for the session
+				isCurrent := false
+				if app.Session.Revert.MessageID != "" {
+					// When reverted, Session.Revert.MessageID contains the NEXT user message ID
+					// So we need to find the previous user message to highlight the correct one
+					for i, navItem := range items {
+						if navItem.messageID == app.Session.Revert.MessageID && i > 0 {
+							// Found the next message, so the previous one is current
+							isCurrent = item.messageID == items[i-1].messageID
+							break
+						}
+					}
+				} else if len(app.Messages) > 0 {
+					// If not reverted, highlight the last user message
+					lastUserMsgID := ""
+					for i := len(app.Messages) - 1; i >= 0; i-- {
+						if userMsg, ok := app.Messages[i].Info.(opencode.UserMessage); ok {
+							lastUserMsgID = userMsg.ID
+							break
+						}
+					}
+					isCurrent = item.messageID == lastUserMsgID
+				}
+				// Only show the dot if undo/redo/restore is available
+				showDot := app.Session.Revert.MessageID != ""
+				return item.Render(selected, width, false, baseStyle, isCurrent && showDot)
 			},
 			},
 		),
 		),
-		list.WithSelectableFunc(func(item navigationItem) bool {
+		list.WithSelectableFunc(func(item timelineItem) bool {
 			return true
 			return true
 		}),
 		}),
 	)
 	)
 	listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
 	listComponent.SetMaxWidth(layout.Current.Container.Width - 12)
 
 
-	return &navigationDialog{
+	return &timelineDialog{
 		list: listComponent,
 		list: listComponent,
 		app:  app,
 		app:  app,
 		modal: modal.New(
 		modal: modal.New(
-			modal.WithTitle("Jump to Message"),
+			modal.WithTitle("Session Timeline"),
 			modal.WithMaxWidth(layout.Current.Container.Width-8),
 			modal.WithMaxWidth(layout.Current.Container.Width-8),
 		),
 		),
 	}
 	}

+ 4 - 4
packages/tui/internal/tui/tui.go

@@ -728,8 +728,8 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 		case "/tui/open-sessions":
 		case "/tui/open-sessions":
 			sessionDialog := dialog.NewSessionDialog(a.app)
 			sessionDialog := dialog.NewSessionDialog(a.app)
 			a.modal = sessionDialog
 			a.modal = sessionDialog
-		case "/tui/open-navigation":
-			navigationDialog := dialog.NewNavigationDialog(a.app)
+		case "/tui/open-timeline":
+			navigationDialog := dialog.NewTimelineDialog(a.app)
 			a.modal = navigationDialog
 			a.modal = navigationDialog
 		case "/tui/open-themes":
 		case "/tui/open-themes":
 			themeDialog := dialog.NewThemeDialog()
 			themeDialog := dialog.NewThemeDialog()
@@ -1146,11 +1146,11 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) {
 	case commands.SessionListCommand:
 	case commands.SessionListCommand:
 		sessionDialog := dialog.NewSessionDialog(a.app)
 		sessionDialog := dialog.NewSessionDialog(a.app)
 		a.modal = sessionDialog
 		a.modal = sessionDialog
-	case commands.SessionNavigationCommand:
+	case commands.SessionTimelineCommand:
 		if a.app.Session.ID == "" {
 		if a.app.Session.ID == "" {
 			return a, toast.NewErrorToast("No active session")
 			return a, toast.NewErrorToast("No active session")
 		}
 		}
-		navigationDialog := dialog.NewNavigationDialog(a.app)
+		navigationDialog := dialog.NewTimelineDialog(a.app)
 		a.modal = navigationDialog
 		a.modal = navigationDialog
 	case commands.SessionShareCommand:
 	case commands.SessionShareCommand:
 		if a.app.Session.ID == "" {
 		if a.app.Session.ID == "" {