From cd3d91209ab843a5a84eec8aae371510ab9e3178 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 18 Aug 2025 12:50:43 +0200 Subject: tweak(timeline): add a dot to the session timeline modal for better visual cue of session's revert point (#1978) --- packages/opencode/src/config/config.ts | 1 + packages/tui/internal/commands/command.go | 71 ++--- .../tui/internal/components/dialog/navigation.go | 315 ------------------ .../tui/internal/components/dialog/timeline.go | 353 +++++++++++++++++++++ packages/tui/internal/tui/tui.go | 8 +- 5 files changed, 393 insertions(+), 355 deletions(-) delete mode 100644 packages/tui/internal/components/dialog/navigation.go create mode 100644 packages/tui/internal/components/dialog/timeline.go diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a41b445d7..752014c53 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -229,6 +229,7 @@ export namespace Config { session_export: z.string().optional().default("x").describe("Export session to editor"), session_new: z.string().optional().default("n").describe("Create a new session"), session_list: z.string().optional().default("l").describe("List all sessions"), + session_timeline: z.string().optional().default("g").describe("Show session timeline"), session_share: z.string().optional().default("s").describe("Share current session"), session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("esc").describe("Interrupt current session"), diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index a4a5e4f7f..bd5d61b95 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -107,7 +107,6 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command { } const ( - SessionChildCycleCommand CommandName = "session_child_cycle" SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse" ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" @@ -119,40 +118,40 @@ const ( EditorOpenCommand CommandName = "editor_open" SessionNewCommand CommandName = "session_new" SessionListCommand CommandName = "session_list" - SessionNavigationCommand CommandName = "session_navigation" + SessionTimelineCommand CommandName = "session_timeline" SessionShareCommand CommandName = "session_share" 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 { @@ -216,10 +215,10 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Trigger: []string{"sessions", "resume", "continue"}, }, { - Name: SessionNavigationCommand, - Description: "jump to message", + Name: SessionTimelineCommand, + Description: "show session timeline", Keybindings: parseBindings("g"), - Trigger: []string{"jump", "goto", "navigate"}, + Trigger: []string{"timeline", "history", "goto"}, }, { Name: SessionShareCommand, diff --git a/packages/tui/internal/components/dialog/navigation.go b/packages/tui/internal/components/dialog/navigation.go deleted file mode 100644 index 3a4cb424a..000000000 --- a/packages/tui/internal/components/dialog/navigation.go +++ /dev/null @@ -1,315 +0,0 @@ -package dialog - -import ( - "fmt" - "strings" - "time" - - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/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() - infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).Render - textStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Text()).Render - - // Format timestamp - only apply color when not selected - var timeStr string - var timeVisualLen int - if selected { - timeStr = n.timestamp.Format("15:04") + " " - timeVisualLen = lipgloss.Width(timeStr) - } else { - timeStr = infoStyle(n.timestamp.Format("15:04") + " ") - timeVisualLen = lipgloss.Width(timeStr) - } - - // 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 { - toolInfo = infoStyle(toolInfoText) - } - toolInfoVisualLen = lipgloss.Width(toolInfo) - } - - // Calculate available space for content - // Reserve space for: timestamp + space + toolInfo + padding + some buffer - reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4 - contentWidth := max(width-reservedSpace, 8) - - truncatedContent := truncate.StringWithTail( - strings.Split(n.content, "\n")[0], - uint(contentWidth), - "...", - ) - - // Apply normal text color to content for non-selected items - var styledContent string - if selected { - styledContent = truncatedContent - } else { - styledContent = textStyle(truncatedContent) - } - - // Create the line with proper spacing - content left-aligned, tools right-aligned - var text string - text = timeStr + styledContent - if toolInfo != "" { - bgColor := t.BackgroundPanel() - if selected { - bgColor = t.Primary() - } - text = layout.Render( - layout.FlexOptions{ - Background: &bgColor, - Direction: layout.Row, - Justify: layout.JustifySpaceBetween, - Align: layout.AlignStretch, - Width: width - 2, - }, - layout.FlexItem{ - View: text, - }, - layout.FlexItem{ - View: toolInfo, - }, - ) - } - - 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.Text()). - Background(t.BackgroundPanel()). - Bold(true). - 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), - ), - } -} diff --git a/packages/tui/internal/components/dialog/timeline.go b/packages/tui/internal/components/dialog/timeline.go new file mode 100644 index 000000000..f2eeb7fb4 --- /dev/null +++ b/packages/tui/internal/components/dialog/timeline.go @@ -0,0 +1,353 @@ +package dialog + +import ( + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/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" +) + +// TimelineDialog interface for the session timeline dialog +type TimelineDialog 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 +} + +// timelineItem represents a user message in the timeline list +type timelineItem 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 timelineItem) Render( + selected bool, + width int, + isFirstInViewport bool, + baseStyle styles.Style, + isCurrent bool, +) string { + t := theme.CurrentTheme() + infoStyle := baseStyle.Background(t.BackgroundPanel()).Foreground(t.Info()).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 + var timeStr string + var timeVisualLen int + if selected { + timeStr = n.timestamp.Format("15:04") + " " + dot + timeVisualLen = lipgloss.Width(n.timestamp.Format("15:04")+" ") + dotVisualLen + } else { + 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 + toolInfo := "" + toolInfoVisualLen := 0 + if n.toolCount > 0 { + toolInfoText := fmt.Sprintf("(%d tools)", n.toolCount) + if selected { + toolInfo = toolInfoText + } else { + toolInfo = infoStyle(toolInfoText) + } + toolInfoVisualLen = lipgloss.Width(toolInfo) + } + + // Calculate available space for content + // Reserve space for: timestamp + dot + space + toolInfo + padding + some buffer + reservedSpace := timeVisualLen + 1 + toolInfoVisualLen + 4 + contentWidth := max(width-reservedSpace, 8) + + truncatedContent := truncate.StringWithTail( + strings.Split(n.content, "\n")[0], + uint(contentWidth), + "...", + ) + + // Apply normal text color to content for non-selected items + var styledContent string + if selected { + styledContent = truncatedContent + } else { + styledContent = textStyle(truncatedContent) + } + + // Create the line with proper spacing - content left-aligned, tools right-aligned + var text string + text = timeStr + styledContent + if toolInfo != "" { + bgColor := t.BackgroundPanel() + if selected { + bgColor = t.Primary() + } + text = layout.Render( + layout.FlexOptions{ + Background: &bgColor, + Direction: layout.Row, + Justify: layout.JustifySpaceBetween, + Align: layout.AlignStretch, + Width: width - 2, + }, + layout.FlexItem{ + View: text, + }, + layout.FlexItem{ + View: toolInfo, + }, + ) + } + + 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 timelineItem) Selectable() bool { + return true +} + +type timelineDialog struct { + width int + height int + modal *modal.Modal + list list.List[timelineItem] + app *app.App +} + +func (n *timelineDialog) Init() tea.Cmd { + return nil +} + +func (n *timelineDialog) 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[timelineItem]) + + // 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[timelineItem]) + return n, cmd +} + +func (n *timelineDialog) Render(background string) string { + listView := n.list.View() + + t := theme.CurrentTheme() + keyStyle := styles.NewStyle(). + Foreground(t.Text()). + Background(t.BackgroundPanel()). + Bold(true). + 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 *timelineDialog) 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 +} + +// 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 + 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, timelineItem{ + messageID: userMsg.ID, + content: preview, + timestamp: time.UnixMilli(int64(userMsg.Time.Created)), + index: i, + toolCount: toolCount, + }) + } + } + + listComponent := list.NewListComponent( + list.WithItems(items), + list.WithMaxVisibleHeight[timelineItem](12), + list.WithFallbackMessage[timelineItem]("No user messages in this session"), + list.WithAlphaNumericKeys[timelineItem](true), + list.WithRenderFunc( + 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 timelineItem) bool { + return true + }), + ) + listComponent.SetMaxWidth(layout.Current.Container.Width - 12) + + return &timelineDialog{ + list: listComponent, + app: app, + modal: modal.New( + modal.WithTitle("Session Timeline"), + modal.WithMaxWidth(layout.Current.Container.Width-8), + ), + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index e797e2882..26a1ba25a 100644 --- a/packages/tui/internal/tui/tui.go +++ b/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": sessionDialog := dialog.NewSessionDialog(a.app) a.modal = sessionDialog - case "/tui/open-navigation": - navigationDialog := dialog.NewNavigationDialog(a.app) + case "/tui/open-timeline": + navigationDialog := dialog.NewTimelineDialog(a.app) a.modal = navigationDialog case "/tui/open-themes": themeDialog := dialog.NewThemeDialog() @@ -1146,11 +1146,11 @@ func (a Model) executeCommand(command commands.Command) (tea.Model, tea.Cmd) { case commands.SessionListCommand: sessionDialog := dialog.NewSessionDialog(a.app) a.modal = sessionDialog - case commands.SessionNavigationCommand: + case commands.SessionTimelineCommand: if a.app.Session.ID == "" { return a, toast.NewErrorToast("No active session") } - navigationDialog := dialog.NewNavigationDialog(a.app) + navigationDialog := dialog.NewTimelineDialog(a.app) a.modal = navigationDialog case commands.SessionShareCommand: if a.app.Session.ID == "" { -- cgit v1.2.3