diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go index 70a267a5..a4a5e4f7 100644 --- a/packages/tui/internal/commands/command.go +++ b/packages/tui/internal/commands/command.go @@ -107,41 +107,52 @@ func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command { } const ( - AppHelpCommand CommandName = "app_help" - AppExitCommand CommandName = "app_exit" - ThemeListCommand CommandName = "theme_list" - ProjectInitCommand CommandName = "project_init" - EditorOpenCommand CommandName = "editor_open" - ToolDetailsCommand CommandName = "tool_details" - ThinkingBlocksCommand CommandName = "thinking_blocks" - SessionNewCommand CommandName = "session_new" - SessionListCommand CommandName = "session_list" - SessionShareCommand CommandName = "session_share" - SessionUnshareCommand CommandName = "session_unshare" - SessionInterruptCommand CommandName = "session_interrupt" - SessionCompactCommand CommandName = "session_compact" - SessionExportCommand CommandName = "session_export" + SessionChildCycleCommand CommandName = "session_child_cycle" SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse" - MessagesPageUpCommand CommandName = "messages_page_up" - MessagesPageDownCommand CommandName = "messages_page_down" - MessagesHalfPageUpCommand CommandName = "messages_half_page_up" - MessagesHalfPageDownCommand CommandName = "messages_half_page_down" - MessagesFirstCommand CommandName = "messages_first" - MessagesLastCommand CommandName = "messages_last" - MessagesCopyCommand CommandName = "messages_copy" - MessagesUndoCommand CommandName = "messages_undo" - MessagesRedoCommand CommandName = "messages_redo" - ModelListCommand CommandName = "model_list" - ModelCycleRecentCommand CommandName = "model_cycle_recent" ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" - AgentListCommand CommandName = "agent_list" AgentCycleCommand CommandName = "agent_cycle" AgentCycleReverseCommand CommandName = "agent_cycle_reverse" - InputClearCommand CommandName = "input_clear" - InputPasteCommand CommandName = "input_paste" - InputSubmitCommand CommandName = "input_submit" - InputNewlineCommand CommandName = "input_newline" + AppHelpCommand CommandName = "app_help" + SwitchAgentCommand CommandName = "switch_agent" + SwitchAgentReverseCommand CommandName = "switch_agent_reverse" + EditorOpenCommand CommandName = "editor_open" + SessionNewCommand CommandName = "session_new" + SessionListCommand CommandName = "session_list" + SessionNavigationCommand CommandName = "session_navigation" + 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" ) func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { @@ -204,6 +215,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry { Keybindings: parseBindings("l"), Trigger: []string{"sessions", "resume", "continue"}, }, + { + Name: SessionNavigationCommand, + Description: "jump to message", + Keybindings: parseBindings("g"), + Trigger: []string{"jump", "goto", "navigate"}, + }, { Name: SessionShareCommand, Description: "share session", diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go index 0be2f462..1ee72b9e 100644 --- a/packages/tui/internal/components/chat/messages.go +++ b/packages/tui/internal/components/chat/messages.go @@ -39,6 +39,7 @@ type MessagesComponent interface { CopyLastMessage() (tea.Model, tea.Cmd) UndoLastMessage() (tea.Model, tea.Cmd) RedoLastMessage() (tea.Model, tea.Cmd) + ScrollToMessage(messageID string) (tea.Model, tea.Cmd) } type messagesComponent struct { @@ -57,6 +58,7 @@ type messagesComponent struct { partCount int lineCount int selection *selection + messagePositions map[string]int // map message ID to line position } type selection struct { @@ -228,6 +230,7 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.rendering = false m.clipboard = msg.clipboard m.loading = false + m.messagePositions = msg.messagePositions m.tail = m.viewport.AtBottom() // Preserve scroll across reflow @@ -256,11 +259,12 @@ func (m *messagesComponent) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } type renderCompleteMsg struct { - viewport viewport.Model - clipboard []string - header string - partCount int - lineCount int + viewport viewport.Model + clipboard []string + header string + partCount int + lineCount int + messagePositions map[string]int } func (m *messagesComponent) renderView() tea.Cmd { @@ -286,6 +290,7 @@ func (m *messagesComponent) renderView() tea.Cmd { blocks := make([]string, 0) partCount := 0 lineCount := 0 + messagePositions := make(map[string]int) // Track message ID to line position orphanedToolCalls := make([]opencode.ToolPart, 0) @@ -308,6 +313,9 @@ func (m *messagesComponent) renderView() tea.Cmd { switch casted := message.Info.(type) { case opencode.UserMessage: + // Track the position of this user message + messagePositions[casted.ID] = lineCount + if casted.ID == m.app.Session.Revert.MessageID { reverted = true revertedMessageCount = 1 @@ -767,11 +775,12 @@ func (m *messagesComponent) renderView() tea.Cmd { } return renderCompleteMsg{ - header: header, - clipboard: clipboard, - viewport: viewport, - partCount: partCount, - lineCount: lineCount, + header: header, + clipboard: clipboard, + viewport: viewport, + partCount: partCount, + lineCount: lineCount, + messagePositions: messagePositions, } } } @@ -1190,6 +1199,18 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) { } } +func (m *messagesComponent) ScrollToMessage(messageID string) (tea.Model, tea.Cmd) { + if m.messagePositions == nil { + return m, nil + } + + if position, exists := m.messagePositions[messageID]; exists { + m.viewport.SetYOffset(position) + m.tail = false // Stop auto-scrolling to bottom when manually navigating + } + return m, nil +} + func NewMessagesComponent(app *app.App) MessagesComponent { vp := viewport.New() vp.KeyMap = viewport.KeyMap{} @@ -1214,5 +1235,6 @@ func NewMessagesComponent(app *app.App) MessagesComponent { showThinkingBlocks: showThinkingBlocks, cache: NewPartCache(), tail: true, + messagePositions: make(map[string]int), } } diff --git a/packages/tui/internal/components/dialog/navigation.go b/packages/tui/internal/components/dialog/navigation.go new file mode 100644 index 00000000..2f8bb562 --- /dev/null +++ b/packages/tui/internal/components/dialog/navigation.go @@ -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), + ), + } +} diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 3b543fc5..f730cbdf 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -631,7 +631,39 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, util.CmdHandler(app.SessionLoadedMsg{}) case app.SessionCreatedMsg: a.app.Session = msg.Session - return a, util.CmdHandler(app.SessionLoadedMsg{}) + case dialog.ScrollToMessageMsg: + updated, cmd := a.messages.ScrollToMessage(msg.MessageID) + a.messages = updated.(chat.MessagesComponent) + cmds = append(cmds, cmd) + case dialog.RestoreToMessageMsg: + cmd := func() tea.Msg { + // Find next user message after target + var nextMessageID string + for i := msg.Index + 1; i < len(a.app.Messages); i++ { + if userMsg, ok := a.app.Messages[i].Info.(opencode.UserMessage); ok { + nextMessageID = userMsg.ID + break + } + } + + var response *opencode.Session + var err error + + if nextMessageID == "" { + // Last message - use unrevert to restore full conversation + response, err = a.app.Client.Session.Unrevert(context.Background(), a.app.Session.ID) + } else { + // Revert to next message to make target the last visible + response, err = a.app.Client.Session.Revert(context.Background(), a.app.Session.ID, + opencode.SessionRevertParams{MessageID: opencode.F(nextMessageID)}) + } + + if err != nil || response == nil { + return toast.NewErrorToast("Failed to restore to message") + } + return app.MessageRevertedMsg{Session: *response, Message: app.Message{}} + } + cmds = append(cmds, cmd) case app.MessageRevertedMsg: if msg.Session.ID == a.app.Session.ID { a.app.Session = &msg.Session @@ -691,6 +723,9 @@ 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) + a.modal = navigationDialog case "/tui/open-themes": themeDialog := dialog.NewThemeDialog() a.modal = themeDialog @@ -1106,6 +1141,12 @@ 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: + if a.app.Session.ID == "" { + return a, toast.NewErrorToast("No active session") + } + navigationDialog := dialog.NewNavigationDialog(a.app) + a.modal = navigationDialog case commands.SessionShareCommand: if a.app.Session.ID == "" { return a, nil diff --git a/packages/web/src/content/docs/docs/keybinds.mdx b/packages/web/src/content/docs/docs/keybinds.mdx index 6fd6148e..92d3a9a2 100644 --- a/packages/web/src/content/docs/docs/keybinds.mdx +++ b/packages/web/src/content/docs/docs/keybinds.mdx @@ -20,6 +20,7 @@ opencode has a list of keybinds that you can customize through the opencode conf "session_export": "x", "session_new": "n", "session_list": "l", + "session_navigation": "g", "session_share": "s", "session_unshare": "none", "session_interrupt": "esc",