summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorspoons-and-mirrors <[email protected]>2025-08-15 20:23:21 +0200
committerGitHub <[email protected]>2025-08-15 13:23:21 -0500
commit69117fa453b6a0e91b275a90663b471a627111d3 (patch)
treeb3b60b9b931c4e73cca006f2d4522a910d16e571
parentdc01071498c4ca0bc9804e4d75ab5ac09d66df9e (diff)
downloadopencode-69117fa453b6a0e91b275a90663b471a627111d3.tar.gz
opencode-69117fa453b6a0e91b275a90663b471a627111d3.zip
feat(TUI): improves UX with message navigation modal to jump and restore to specific messages (#1969)
-rw-r--r--packages/tui/internal/commands/command.go75
-rw-r--r--packages/tui/internal/components/chat/messages.go42
-rw-r--r--packages/tui/internal/components/dialog/navigation.go294
-rw-r--r--packages/tui/internal/tui/tui.go43
-rw-r--r--packages/web/src/content/docs/docs/keybinds.mdx1
5 files changed, 415 insertions, 40 deletions
diff --git a/packages/tui/internal/commands/command.go b/packages/tui/internal/commands/command.go
index 70a267a52..a4a5e4f7f 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 (
+
+ SessionChildCycleCommand CommandName = "session_child_cycle"
+ SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse"
+ ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse"
+ AgentCycleCommand CommandName = "agent_cycle"
+ AgentCycleReverseCommand CommandName = "agent_cycle_reverse"
AppHelpCommand CommandName = "app_help"
- AppExitCommand CommandName = "app_exit"
- ThemeListCommand CommandName = "theme_list"
- ProjectInitCommand CommandName = "project_init"
+ SwitchAgentCommand CommandName = "switch_agent"
+ SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
EditorOpenCommand CommandName = "editor_open"
- ToolDetailsCommand CommandName = "tool_details"
- ThinkingBlocksCommand CommandName = "thinking_blocks"
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"
- 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"
+ 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 {
@@ -205,6 +216,12 @@ func LoadFromConfig(config *opencode.Config) CommandRegistry {
Trigger: []string{"sessions", "resume", "continue"},
},
{
+ Name: SessionNavigationCommand,
+ Description: "jump to message",
+ Keybindings: parseBindings("<leader>g"),
+ Trigger: []string{"jump", "goto", "navigate"},
+ },
+ {
Name: SessionShareCommand,
Description: "share session",
Keybindings: parseBindings("<leader>s"),
diff --git a/packages/tui/internal/components/chat/messages.go b/packages/tui/internal/components/chat/messages.go
index 0be2f462c..1ee72b9e5 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 000000000..2f8bb5626
--- /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 3b543fc52..f730cbdf4 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 6fd6148e1..92d3a9a21 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": "<leader>x",
"session_new": "<leader>n",
"session_list": "<leader>l",
+ "session_navigation": "<leader>g",
"session_share": "<leader>s",
"session_unshare": "none",
"session_interrupt": "esc",