From f230c316d2ec33c44e53ed6dac10074cce7e25d9 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Tue, 3 Jun 2025 11:41:06 +0200 Subject: [PATCH] commands,model selector --- internal/llm/models/models.go | 38 +- internal/tui/components/core/helpers.go | 7 +- internal/tui/components/core/list/list.go | 45 ++- internal/tui/components/dialog/filepicker.go | 11 +- internal/tui/components/dialog/models.go | 374 ------------------ internal/tui/components/dialog/session.go | 233 ----------- .../components/dialogs/commands/commands.go | 141 +++++-- .../tui/components/dialogs/commands/item.go | 25 +- .../tui/components/dialogs/commands/keys.go | 18 +- .../tui/components/dialogs/models/keys.go | 56 +++ .../tui/components/dialogs/models/models.go | 261 ++++++++++++ .../components/dialogs/sessions/sessions.go | 8 +- internal/tui/keys.go | 20 +- internal/tui/tui.go | 22 +- todos.md | 4 +- 15 files changed, 523 insertions(+), 740 deletions(-) delete mode 100644 internal/tui/components/dialog/models.go delete mode 100644 internal/tui/components/dialog/session.go create mode 100644 internal/tui/components/dialogs/models/keys.go create mode 100644 internal/tui/components/dialogs/models/models.go diff --git a/internal/llm/models/models.go b/internal/llm/models/models.go index 47d217184de54f7e2937286cd2c64c9e98c4a02b..50e8723989ccb268a9f515b4c693662654fa38d5 100644 --- a/internal/llm/models/models.go +++ b/internal/llm/models/models.go @@ -34,44 +34,8 @@ const ( ProviderMock ModelProvider = "__mock" ) -// Providers in order of popularity -var ProviderPopularity = map[ModelProvider]int{ - ProviderAnthropic: 1, - ProviderOpenAI: 2, - ProviderGemini: 3, - ProviderGROQ: 4, - ProviderOpenRouter: 5, - ProviderBedrock: 6, - ProviderAzure: 7, - ProviderVertexAI: 8, -} - var SupportedModels = map[ModelID]Model{ - // - // // GEMINI - // GEMINI25: { - // ID: GEMINI25, - // Name: "Gemini 2.5 Pro", - // Provider: ProviderGemini, - // APIModel: "gemini-2.5-pro-exp-03-25", - // CostPer1MIn: 0, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0, - // CostPer1MOut: 0, - // }, - // - // GRMINI20Flash: { - // ID: GRMINI20Flash, - // Name: "Gemini 2.0 Flash", - // Provider: ProviderGemini, - // APIModel: "gemini-2.0-flash", - // CostPer1MIn: 0.1, - // CostPer1MInCached: 0, - // CostPer1MOutCached: 0.025, - // CostPer1MOut: 0.4, - // }, - // - // // Bedrock + // Bedrock BedrockClaude37Sonnet: { ID: BedrockClaude37Sonnet, Name: "Bedrock: Claude 3.7 Sonnet", diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index 994433e3169d808c6e717385caf6dc57d4a383e2..31869a587ae73133c3c8fbcc50129ab0b0632a9c 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/helpers.go @@ -12,10 +12,11 @@ import ( func Section(text string, width int) string { t := styles.CurrentTheme() char := "─" - length := len(text) + 1 + length := lipgloss.Width(text) + 1 remainingWidth := width - length + lineStyle := t.S().Base.Foreground(t.Border) if remainingWidth > 0 { - text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth)) + text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) } return text } @@ -23,7 +24,7 @@ func Section(text string, width int) string { func Title(title string, width int) string { t := styles.CurrentTheme() char := "╱" - length := len(title) + 1 + length := lipgloss.Width(title) + 1 remainingWidth := width - length lineStyle := t.S().Base.Foreground(t.Primary) titleStyle := t.S().Base.Foreground(t.Primary) diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 996bd3c11e716f0b79f503736783ba3cb431de2f..6c7d34777f7622d1f474b5dfe7e4fc3553a1420e 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -133,11 +133,13 @@ type model struct { allItems []util.Model // The actual list items gapSize int // Number of empty lines between items padding []int // Padding around the list content + wrapNavigation bool // Whether to wrap navigation at the ends filterable bool // Whether items can be filtered filterPlaceholder string // Placeholder text for filter input filteredItems []util.Model // Filtered items based on current search input textinput.Model // Input field for filtering items + inputStyle lipgloss.Style // Style for the input field hideFilterInput bool // Whether to hide the filter input field currentSearch string // Current search term for filtering } @@ -204,10 +206,26 @@ func WithFilterPlaceholder(placeholder string) listOptions { } } +// WithInputStyle sets the style for the filter input field. +func WithInputStyle(style lipgloss.Style) listOptions { + return func(m *model) { + m.inputStyle = style + } +} + +// WithWrapNavigation enables wrapping navigation at the ends of the list. +func WithWrapNavigation(wrap bool) listOptions { + return func(m *model) { + m.wrapNavigation = wrap + } +} + // New creates a new list model with the specified options. // The list starts with no items selected and requires SetItems to be called // or items to be provided via WithItems option. func New(opts ...listOptions) ListModel { + t := styles.CurrentTheme() + m := &model{ help: help.New(), keyMap: DefaultKeyMap(), @@ -218,6 +236,7 @@ func New(opts ...listOptions) ListModel { padding: []int{}, selectionState: selectionState{selectedIndex: NoSelection}, filterPlaceholder: "Type to filter...", + inputStyle: t.S().Base.Padding(0, 1, 1, 1), } for _, opt := range opts { opt(m) @@ -281,7 +300,7 @@ func (m *model) View() tea.View { if m.filterable && !m.hideFilterInput { content = lipgloss.JoinVertical( lipgloss.Left, - m.inputStyle().Render(m.input.View()), + m.inputStyle.Render(m.input.View()), content, ) } @@ -400,7 +419,7 @@ func (m *model) renderVisibleForward() { renderer := &forwardRenderer{ model: m, start: 0, - cutoff: m.viewState.offset + m.listHeight(), + cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd items: m.filteredItems, realIdx: m.renderState.lastIndex, } @@ -420,7 +439,7 @@ func (m *model) renderVisibleReverse() { renderer := &reverseRenderer{ model: m, start: 0, - cutoff: m.viewState.offset + m.listHeight(), + cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, items: m.filteredItems, realIdx: m.renderState.lastIndex, } @@ -567,6 +586,10 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string { // Handles focus management and ensures the selected item remains visible. // Skips section headers during navigation. func (m *model) selectPreviousItem() tea.Cmd { + if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation { + // If at the beginning and wrapping is enabled, go to the last item + return m.goToBottom() + } if m.selectionState.selectedIndex <= 0 { return nil } @@ -580,8 +603,9 @@ func (m *model) selectPreviousItem() tea.Cmd { } // If we went past the beginning, stay at the first non-header item - if m.selectionState.selectedIndex < 0 { - m.selectionState.selectedIndex = m.findFirstSelectableItem() + if m.selectionState.selectedIndex <= 0 { + cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed + return tea.Batch(cmds...) } cmds = append(cmds, m.focusSelected()) @@ -593,6 +617,10 @@ func (m *model) selectPreviousItem() tea.Cmd { // Handles focus management and ensures the selected item remains visible. // Skips section headers during navigation. func (m *model) selectNextItem() tea.Cmd { + if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation { + // If at the end and wrapping is enabled, go to the first item + return m.goToTop() + } if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 { return nil } @@ -1008,6 +1036,9 @@ func (m *model) listHeight() int { case 3, 4: height -= m.padding[0] + m.padding[2] } + if m.filterable && !m.hideFilterInput { + height -= lipgloss.Height(m.inputStyle.Render("dummy")) + } return max(0, height) } @@ -1107,10 +1138,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd { return tea.Batch(cmds...) } -func (c *model) inputStyle() lipgloss.Style { - return styles.BaseStyle().Padding(0, 1, 1, 1) -} - // section represents a group of items under a section header. type section struct { header SectionHeader diff --git a/internal/tui/components/dialog/filepicker.go b/internal/tui/components/dialog/filepicker.go index 8edb182701a6294521098550e40ee661e727d919..d6d5e10112ad13cc8b93ebd54731610a6c4b8eea 100644 --- a/internal/tui/components/dialog/filepicker.go +++ b/internal/tui/components/dialog/filepicker.go @@ -15,7 +15,6 @@ import ( tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/app" - "github.com/opencode-ai/opencode/internal/config" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/message" "github.com/opencode-ai/opencode/internal/tui/image" @@ -222,11 +221,11 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) { - modeInfo := GetSelectedModel(config.Get()) - if !modeInfo.SupportsAttachments { - logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) - return f, nil - } + // modeInfo := GetSelectedModel(config.Get()) + // if !modeInfo.SupportsAttachments { + // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name)) + // return f, nil + // } selectedFilePath := f.selectedFile if !isExtSupported(selectedFilePath) { diff --git a/internal/tui/components/dialog/models.go b/internal/tui/components/dialog/models.go deleted file mode 100644 index d14ffbf9241d749095aa9a03a8e00b33489ba4b4..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/models.go +++ /dev/null @@ -1,374 +0,0 @@ -package dialog - -import ( - "fmt" - "slices" - "strings" - - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/config" - "github.com/opencode-ai/opencode/internal/llm/models" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -const ( - numVisibleModels = 10 - maxDialogWidth = 40 -) - -// ModelSelectedMsg is sent when a model is selected -type ModelSelectedMsg struct { - Model models.Model -} - -// CloseModelDialogMsg is sent when a model is selected -type CloseModelDialogMsg struct{} - -// ModelDialog interface for the model selection dialog -type ModelDialog interface { - util.Model - layout.Bindings -} - -type modelDialogCmp struct { - models []models.Model - provider models.ModelProvider - availableProviders []models.ModelProvider - - selectedIdx int - width int - height int - scrollOffset int - hScrollOffset int - hScrollPossible bool -} - -type modelKeyMap struct { - Up key.Binding - Down key.Binding - Left key.Binding - Right key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding - H key.Binding - L key.Binding -} - -var modelKeys = modelKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous model"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next model"), - ), - Left: key.NewBinding( - key.WithKeys("left"), - key.WithHelp("←", "scroll left"), - ), - Right: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("→", "scroll right"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select model"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next model"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous model"), - ), - H: key.NewBinding( - key.WithKeys("h"), - key.WithHelp("h", "scroll left"), - ), - L: key.NewBinding( - key.WithKeys("l"), - key.WithHelp("l", "scroll right"), - ), -} - -func (m *modelDialogCmp) Init() tea.Cmd { - m.setupModels() - return nil -} - -func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K): - m.moveSelectionUp() - case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J): - m.moveSelectionDown() - case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H): - if m.hScrollPossible { - m.switchProvider(-1) - } - case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L): - if m.hScrollPossible { - m.switchProvider(1) - } - case key.Matches(msg, modelKeys.Enter): - util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name)) - return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]}) - case key.Matches(msg, modelKeys.Escape): - return m, util.CmdHandler(CloseModelDialogMsg{}) - } - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - } - - return m, nil -} - -// moveSelectionUp moves the selection up or wraps to bottom -func (m *modelDialogCmp) moveSelectionUp() { - if m.selectedIdx > 0 { - m.selectedIdx-- - } else { - m.selectedIdx = len(m.models) - 1 - m.scrollOffset = max(0, len(m.models)-numVisibleModels) - } - - // Keep selection visible - if m.selectedIdx < m.scrollOffset { - m.scrollOffset = m.selectedIdx - } -} - -// moveSelectionDown moves the selection down or wraps to top -func (m *modelDialogCmp) moveSelectionDown() { - if m.selectedIdx < len(m.models)-1 { - m.selectedIdx++ - } else { - m.selectedIdx = 0 - m.scrollOffset = 0 - } - - // Keep selection visible - if m.selectedIdx >= m.scrollOffset+numVisibleModels { - m.scrollOffset = m.selectedIdx - (numVisibleModels - 1) - } -} - -func (m *modelDialogCmp) switchProvider(offset int) { - newOffset := m.hScrollOffset + offset - - // Ensure we stay within bounds - if newOffset < 0 { - newOffset = len(m.availableProviders) - 1 - } - if newOffset >= len(m.availableProviders) { - newOffset = 0 - } - - m.hScrollOffset = newOffset - m.provider = m.availableProviders[m.hScrollOffset] - m.setupModelsForProvider(m.provider) -} - -func (m *modelDialogCmp) View() tea.View { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - // Capitalize first letter of provider name - providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:]) - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxDialogWidth). - Padding(0, 0, 1). - Render(fmt.Sprintf("Select %s Model", providerName)) - - // Render visible models - endIdx := min(m.scrollOffset+numVisibleModels, len(m.models)) - modelItems := make([]string, 0, endIdx-m.scrollOffset) - - for i := m.scrollOffset; i < endIdx; i++ { - itemStyle := baseStyle.Width(maxDialogWidth) - if i == m.selectedIdx { - itemStyle = itemStyle.Background(t.Primary()). - Foreground(t.Background()).Bold(true) - } - modelItems = append(modelItems, itemStyle.Render(m.models[i].Name)) - } - - scrollIndicator := m.getScrollIndicators(maxDialogWidth) - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)), - scrollIndicator, - ) - - return tea.NewView( - baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(lipgloss.Width(content) + 4). - Render(content), - ) -} - -func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string { - var indicator string - - if len(m.models) > numVisibleModels { - if m.scrollOffset > 0 { - indicator += "↑ " - } - if m.scrollOffset+numVisibleModels < len(m.models) { - indicator += "↓ " - } - } - - if m.hScrollPossible { - if m.hScrollOffset > 0 { - indicator = "← " + indicator - } - if m.hScrollOffset < len(m.availableProviders)-1 { - indicator += "→" - } - } - - if indicator == "" { - return "" - } - - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - return baseStyle. - Foreground(t.Primary()). - Width(maxWidth). - Align(lipgloss.Right). - Bold(true). - Render(indicator) -} - -func (m *modelDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(modelKeys) -} - -func (m *modelDialogCmp) setupModels() { - cfg := config.Get() - modelInfo := GetSelectedModel(cfg) - m.availableProviders = getEnabledProviders(cfg) - m.hScrollPossible = len(m.availableProviders) > 1 - - m.provider = modelInfo.Provider - m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider) - - m.setupModelsForProvider(m.provider) -} - -func GetSelectedModel(cfg *config.Config) models.Model { - agentCfg := cfg.Agents[config.AgentCoder] - selectedModelId := agentCfg.Model - return models.SupportedModels[selectedModelId] -} - -func getEnabledProviders(cfg *config.Config) []models.ModelProvider { - var providers []models.ModelProvider - for providerId, provider := range cfg.Providers { - if !provider.Disabled { - providers = append(providers, providerId) - } - } - - // Sort by provider popularity - slices.SortFunc(providers, func(a, b models.ModelProvider) int { - rA := models.ProviderPopularity[a] - rB := models.ProviderPopularity[b] - - // models not included in popularity ranking default to last - if rA == 0 { - rA = 999 - } - if rB == 0 { - rB = 999 - } - return rA - rB - }) - return providers -} - -// findProviderIndex returns the index of the provider in the list, or -1 if not found -func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int { - for i, p := range providers { - if p == provider { - return i - } - } - return -1 -} - -func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) { - cfg := config.Get() - agentCfg := cfg.Agents[config.AgentCoder] - selectedModelId := agentCfg.Model - - m.provider = provider - m.models = getModelsForProvider(provider) - m.selectedIdx = 0 - m.scrollOffset = 0 - - // Try to select the current model if it belongs to this provider - if provider == models.SupportedModels[selectedModelId].Provider { - for i, model := range m.models { - if model.ID == selectedModelId { - m.selectedIdx = i - // Adjust scroll position to keep selected model visible - if m.selectedIdx >= numVisibleModels { - m.scrollOffset = m.selectedIdx - (numVisibleModels - 1) - } - break - } - } - } -} - -func getModelsForProvider(provider models.ModelProvider) []models.Model { - var providerModels []models.Model - for _, model := range models.SupportedModels { - if model.Provider == provider { - providerModels = append(providerModels, model) - } - } - - // reverse alphabetical order (if llm naming was consistent latest would appear first) - slices.SortFunc(providerModels, func(a, b models.Model) int { - if a.Name > b.Name { - return -1 - } else if a.Name < b.Name { - return 1 - } - return 0 - }) - - return providerModels -} - -func NewModelDialogCmp() ModelDialog { - return &modelDialogCmp{} -} diff --git a/internal/tui/components/dialog/session.go b/internal/tui/components/dialog/session.go deleted file mode 100644 index 15a118d6efae995182c3cefa1a4b81ddaa0e5e28..0000000000000000000000000000000000000000 --- a/internal/tui/components/dialog/session.go +++ /dev/null @@ -1,233 +0,0 @@ -package dialog - -import ( - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" - "github.com/opencode-ai/opencode/internal/session" - "github.com/opencode-ai/opencode/internal/tui/layout" - "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" - "github.com/opencode-ai/opencode/internal/tui/util" -) - -// SessionSelectedMsg is sent when a session is selected -type SessionSelectedMsg struct { - Session session.Session -} - -// CloseSessionDialogMsg is sent when the session dialog is closed -type CloseSessionDialogMsg struct{} - -// SessionDialog interface for the session switching dialog -type SessionDialog interface { - util.Model - layout.Bindings - SetSessions(sessions []session.Session) - SetSelectedSession(sessionID string) -} - -type sessionDialogCmp struct { - sessions []session.Session - selectedIdx int - width int - height int - selectedSessionID string -} - -type sessionKeyMap struct { - Up key.Binding - Down key.Binding - Enter key.Binding - Escape key.Binding - J key.Binding - K key.Binding -} - -var sessionKeys = sessionKeyMap{ - Up: key.NewBinding( - key.WithKeys("up"), - key.WithHelp("↑", "previous session"), - ), - Down: key.NewBinding( - key.WithKeys("down"), - key.WithHelp("↓", "next session"), - ), - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select session"), - ), - Escape: key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), - ), - J: key.NewBinding( - key.WithKeys("j"), - key.WithHelp("j", "next session"), - ), - K: key.NewBinding( - key.WithKeys("k"), - key.WithHelp("k", "previous session"), - ), -} - -func (s *sessionDialogCmp) Init() tea.Cmd { - return nil -} - -func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.KeyPressMsg: - switch { - case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K): - if s.selectedIdx > 0 { - s.selectedIdx-- - } - return s, nil - case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J): - if s.selectedIdx < len(s.sessions)-1 { - s.selectedIdx++ - } - return s, nil - case key.Matches(msg, sessionKeys.Enter): - if len(s.sessions) > 0 { - return s, util.CmdHandler(SessionSelectedMsg{ - Session: s.sessions[s.selectedIdx], - }) - } - case key.Matches(msg, sessionKeys.Escape): - return s, util.CmdHandler(CloseSessionDialogMsg{}) - } - case tea.WindowSizeMsg: - s.width = msg.Width - s.height = msg.Height - } - return s, nil -} - -func (s *sessionDialogCmp) View() tea.View { - t := theme.CurrentTheme() - baseStyle := styles.BaseStyle() - - if len(s.sessions) == 0 { - return tea.NewView( - baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Width(40). - Render("No sessions available"), - ) - } - - // Calculate max width needed for session titles - maxWidth := 40 // Minimum width - for _, sess := range s.sessions { - if len(sess.Title) > maxWidth-4 { // Account for padding - maxWidth = len(sess.Title) + 4 - } - } - - maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow - - // Limit height to avoid taking up too much screen space - maxVisibleSessions := min(10, len(s.sessions)) - - // Build the session list - sessionItems := make([]string, 0, maxVisibleSessions) - startIdx := 0 - - // If we have more sessions than can be displayed, adjust the start index - if len(s.sessions) > maxVisibleSessions { - // Center the selected item when possible - halfVisible := maxVisibleSessions / 2 - if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible { - startIdx = s.selectedIdx - halfVisible - } else if s.selectedIdx >= len(s.sessions)-halfVisible { - startIdx = len(s.sessions) - maxVisibleSessions - } - } - - endIdx := min(startIdx+maxVisibleSessions, len(s.sessions)) - - for i := startIdx; i < endIdx; i++ { - sess := s.sessions[i] - itemStyle := baseStyle.Width(maxWidth) - - if i == s.selectedIdx { - itemStyle = itemStyle. - Background(t.Primary()). - Foreground(t.Background()). - Bold(true) - } - - sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title)) - } - - title := baseStyle. - Foreground(t.Primary()). - Bold(true). - Width(maxWidth). - Padding(0, 1). - Render("Switch Session") - - content := lipgloss.JoinVertical( - lipgloss.Left, - title, - baseStyle.Width(maxWidth).Render(""), - baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)), - baseStyle.Width(maxWidth).Render(""), - ) - - return tea.NewView( - baseStyle.Padding(1, 2). - Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()). - Render(content), - ) -} - -func (s *sessionDialogCmp) BindingKeys() []key.Binding { - return layout.KeyMapToSlice(sessionKeys) -} - -func (s *sessionDialogCmp) SetSessions(sessions []session.Session) { - s.sessions = sessions - - // If we have a selected session ID, find its index - if s.selectedSessionID != "" { - for i, sess := range sessions { - if sess.ID == s.selectedSessionID { - s.selectedIdx = i - return - } - } - } - - // Default to first session if selected not found - s.selectedIdx = 0 -} - -func (s *sessionDialogCmp) SetSelectedSession(sessionID string) { - s.selectedSessionID = sessionID - - // Update the selected index if sessions are already loaded - if len(s.sessions) > 0 { - for i, sess := range s.sessions { - if sess.ID == sessionID { - s.selectedIdx = i - return - } - } - } -} - -// NewSessionDialogCmp creates a new session switching dialog -func NewSessionDialogCmp() SessionDialog { - return &sessionDialogCmp{ - sessions: []session.Session{}, - selectedIdx: 0, - selectedSessionID: "", - } -} diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 07292ae123a4b220c01fd9e51c9e0754634ca561..d90d64f9c1878b22bfcf1f61aa0535ce1d304bbe 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -1,23 +1,29 @@ package commands import ( + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/tui/components/chat" "github.com/opencode-ai/opencode/internal/tui/components/completions" + "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/core/list" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) const ( commandsDialogID dialogs.DialogID = "commands" - defaultWidth int = 60 + defaultWidth int = 70 +) + +const ( + SystemCommands int = iota + UserCommands ) // Command represents a command that can be executed @@ -38,11 +44,18 @@ type commandDialogCmp struct { wWidth int // Width of the terminal window wHeight int // Height of the terminal window - commandList list.ListModel - commands []Command - keyMap CommandsDialogKeyMap + commandList list.ListModel + keyMap CommandsDialogKeyMap + help help.Model + commandType int // SystemCommands or UserCommands + userCommands []Command // User-defined commands } +type ( + SwitchSessionsMsg struct{} + SwitchModelMsg struct{} +) + func NewCommandDialog() CommandsDialog { listKeyMap := list.DefaultKeyMap() keyMap := DefaultCommandsDialogKeyMap() @@ -59,11 +72,20 @@ func NewCommandDialog() CommandsDialog { listKeyMap.DownOneItem = keyMap.Next listKeyMap.UpOneItem = keyMap.Previous - commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap)) + t := styles.CurrentTheme() + commandList := list.New( + list.WithFilterable(true), + list.WithKeyMap(listKeyMap), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help return &commandDialogCmp{ commandList: commandList, width: defaultWidth, keyMap: DefaultCommandsDialogKeyMap(), + help: help, + commandType: SystemCommands, } } @@ -72,24 +94,9 @@ func (c *commandDialogCmp) Init() tea.Cmd { if err != nil { return util.ReportError(err) } - c.commands = commands - - commandItems := []util.Model{} - if len(commands) > 0 { - commandItems = append(commandItems, NewItemSection("Custom Commands")) - for _, cmd := range commands { - commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) - } - } - - commandItems = append(commandItems, NewItemSection("Default")) - for _, cmd := range c.defaultCommands() { - c.commands = append(c.commands, cmd) - commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) - } - - c.commandList.SetItems(commandItems) + c.userCommands = commands + c.SetCommandType(c.commandType) return c.commandList.Init() } @@ -112,6 +119,13 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.CmdHandler(dialogs.CloseDialogMsg{}), selectedItem.Handler(selectedItem), ) + case key.Matches(msg, c.keyMap.Tab): + // Toggle command type between System and User commands + if c.commandType == SystemCommands { + return c, c.SetCommandType(UserCommands) + } else { + return c, c.SetCommandType(SystemCommands) + } default: u, cmd := c.commandList.Update(msg) c.commandList = u.(list.ListModel) @@ -122,8 +136,17 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (c *commandDialogCmp) View() tea.View { + t := styles.CurrentTheme() listView := c.commandList.View() - v := tea.NewView(c.style().Render(listView.String())) + radio := c.commandTypeRadio() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio), + listView.String(), + "", + t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)), + ) + v := tea.NewView(c.style().Render(content)) if listView.Cursor() != nil { c := c.moveCursor(listView.Cursor()) v.SetCursor(c) @@ -131,8 +154,36 @@ func (c *commandDialogCmp) View() tea.View { return v } +func (c *commandDialogCmp) commandTypeRadio() string { + t := styles.CurrentTheme() + choices := []string{"System", "User"} + iconSelected := "◉" + iconUnselected := "○" + if c.commandType == SystemCommands { + return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1]) + } + return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1]) +} + func (c *commandDialogCmp) listWidth() int { - return defaultWidth - 4 // 4 for padding + return defaultWidth - 2 // 4 for padding +} + +func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd { + c.commandType = commandType + + var commands []Command + if c.commandType == SystemCommands { + commands = c.defaultCommands() + } else { + commands = c.userCommands + } + + commandItems := []util.Model{} + for _, cmd := range commands { + commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) + } + return c.commandList.SetItems(commandItems) } func (c *commandDialogCmp) listHeight() int { @@ -141,27 +192,25 @@ func (c *commandDialogCmp) listHeight() int { } func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - offset := 10 + 1 + row, col := c.Position() + offset := row + 3 cursor.Y += offset - _, col := c.Position() cursor.X = cursor.X + col + 2 return cursor } func (c *commandDialogCmp) style() lipgloss.Style { - t := theme.CurrentTheme() - return styles.BaseStyle(). + t := styles.CurrentTheme() + return t.S().Base. Width(c.width). - Padding(0, 1, 1, 1). Border(lipgloss.RoundedBorder()). - BorderBackground(t.Background()). - BorderForeground(t.TextMuted()) + BorderForeground(t.BorderFocus) } -func (q *commandDialogCmp) Position() (int, int) { - row := 10 - col := q.wWidth / 2 - col -= q.width / 2 +func (c *commandDialogCmp) Position() (int, int) { + row := c.wHeight/4 - 2 // just a bit above the center + col := c.wWidth / 2 + col -= c.width / 2 return row, col } @@ -197,6 +246,26 @@ func (c *commandDialogCmp) defaultCommands() []Command { } }, }, + { + ID: "switch_session", + Title: "Switch Session", + Description: "Switch to a different session", + Handler: func(cmd Command) tea.Cmd { + return func() tea.Msg { + return SwitchSessionsMsg{} + } + }, + }, + { + ID: "switch_model", + Title: "Switch Model", + Description: "Switch to a different model", + Handler: func(cmd Command) tea.Cmd { + return func() tea.Msg { + return SwitchModelMsg{} + } + }, + }, } } diff --git a/internal/tui/components/dialogs/commands/item.go b/internal/tui/components/dialogs/commands/item.go index 26974d5082046aaa05477f95fddfdeca889c98dc..bc8c11edc2ead5ae52a6c83a678c4df8807e1be5 100644 --- a/internal/tui/components/dialogs/commands/item.go +++ b/internal/tui/components/dialogs/commands/item.go @@ -1,15 +1,12 @@ package commands import ( - "strings" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" + "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/core/list" "github.com/opencode-ai/opencode/internal/tui/layout" "github.com/opencode-ai/opencode/internal/tui/styles" - "github.com/opencode-ai/opencode/internal/tui/theme" "github.com/opencode-ai/opencode/internal/tui/util" ) @@ -19,8 +16,9 @@ type ItemSection interface { list.SectionHeader } type itemSectionModel struct { - width int - title string + width int + title string + noPadding bool // No padding for the section header } func NewItemSection(title string) ItemSection { @@ -38,16 +36,11 @@ func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) { } func (m *itemSectionModel) View() tea.View { - t := theme.CurrentTheme() - title := ansi.Truncate(m.title, m.width-1, "…") - style := styles.BaseStyle().Padding(1, 0, 0, 0).Width(m.width).Foreground(t.TextMuted()).Bold(true) - if len(title) < m.width { - remainingWidth := m.width - lipgloss.Width(title) - if remainingWidth > 0 { - title += " " + strings.Repeat("─", remainingWidth-1) - } - } - return tea.NewView(style.Render(title)) + t := styles.CurrentTheme() + title := ansi.Truncate(m.title, m.width-2, "…") + style := t.S().Base.Padding(1, 1, 0, 1) + title = t.S().Muted.Render(title) + return tea.NewView(style.Render(core.Section(title, m.width-2))) } func (m *itemSectionModel) GetSize() (int, int) { diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 4960f086a64f5356f4b5c1643d1b72076b786df2..7bfe0fb69675c8e2c04edc78d59ac0dda05415cd 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -9,12 +9,13 @@ type CommandsDialogKeyMap struct { Select key.Binding Next key.Binding Previous key.Binding + Tab key.Binding } func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { return CommandsDialogKeyMap{ Select: key.NewBinding( - key.WithKeys("enter", "tab", "ctrl+y"), + key.WithKeys("enter", "ctrl+y"), key.WithHelp("enter", "confirm"), ), Next: key.NewBinding( @@ -25,6 +26,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ), + Tab: key.NewBinding( + key.WithKeys("tab"), + key.WithHelp("tab", "switch selection"), + ), } } @@ -42,9 +47,16 @@ func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding { // ShortHelp implements help.KeyMap. func (k CommandsDialogKeyMap) ShortHelp() []key.Binding { return []key.Binding{ + k.Tab, + key.NewBinding( + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), k.Select, - k.Next, - k.Previous, + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } } diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..50bec18f2f51fd695582d7cf5f799fffaee8d577 --- /dev/null +++ b/internal/tui/components/dialogs/models/keys.go @@ -0,0 +1,56 @@ +package models + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +type KeyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go new file mode 100644 index 0000000000000000000000000000000000000000..b2ee4e8bb6fd7631a03c90c46a7bbb2cab8b274c --- /dev/null +++ b/internal/tui/components/dialogs/models/models.go @@ -0,0 +1,261 @@ +package models + +import ( + "slices" + + "github.com/charmbracelet/bubbles/v2/help" + "github.com/charmbracelet/bubbles/v2/key" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" + "github.com/opencode-ai/opencode/internal/config" + "github.com/opencode-ai/opencode/internal/llm/models" + "github.com/opencode-ai/opencode/internal/tui/components/completions" + "github.com/opencode-ai/opencode/internal/tui/components/core" + "github.com/opencode-ai/opencode/internal/tui/components/core/list" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" + "github.com/opencode-ai/opencode/internal/tui/styles" + "github.com/opencode-ai/opencode/internal/tui/util" +) + +const ( + ID dialogs.DialogID = "models" + + defaultWidth = 60 +) + +// ModelSelectedMsg is sent when a model is selected +type ModelSelectedMsg struct { + Model models.Model +} + +// CloseModelDialogMsg is sent when a model is selected +type CloseModelDialogMsg struct{} + +// ModelDialog interface for the model selection dialog +type ModelDialog interface { + dialogs.DialogModel +} + +type modelDialogCmp struct { + width int + wWidth int // Width of the terminal window + wHeight int // Height of the terminal window + + modelList list.ListModel + keyMap KeyMap + help help.Model +} + +func NewModelDialogCmp() ModelDialog { + listKeyMap := list.DefaultKeyMap() + keyMap := DefaultKeyMap() + + listKeyMap.Down.SetEnabled(false) + listKeyMap.Up.SetEnabled(false) + listKeyMap.NDown.SetEnabled(false) + listKeyMap.NUp.SetEnabled(false) + listKeyMap.HalfPageDown.SetEnabled(false) + listKeyMap.HalfPageUp.SetEnabled(false) + listKeyMap.Home.SetEnabled(false) + listKeyMap.End.SetEnabled(false) + + listKeyMap.DownOneItem = keyMap.Next + listKeyMap.UpOneItem = keyMap.Previous + + t := styles.CurrentTheme() + inputStyle := t.S().Base.Padding(0, 1, 0, 1) + modelList := list.New( + list.WithFilterable(true), + list.WithKeyMap(listKeyMap), + list.WithInputStyle(inputStyle), + list.WithWrapNavigation(true), + ) + help := help.New() + help.Styles = t.S().Help + + return &modelDialogCmp{ + modelList: modelList, + width: defaultWidth, + keyMap: DefaultKeyMap(), + help: help, + } +} + +var ProviderPopularity = map[models.ModelProvider]int{ + models.ProviderAnthropic: 1, + models.ProviderOpenAI: 2, + models.ProviderGemini: 3, + models.ProviderGROQ: 4, + models.ProviderOpenRouter: 5, + models.ProviderBedrock: 6, + models.ProviderAzure: 7, + models.ProviderVertexAI: 8, + models.ProviderXAI: 9, +} + +var ProviderName = map[models.ModelProvider]string{ + models.ProviderAnthropic: "Anthropic", + models.ProviderOpenAI: "OpenAI", + models.ProviderGemini: "Gemini", + models.ProviderGROQ: "Groq", + models.ProviderOpenRouter: "OpenRouter", + models.ProviderBedrock: "AWS Bedrock", + models.ProviderAzure: "Azure", + models.ProviderVertexAI: "VertexAI", + models.ProviderXAI: "xAI", +} + +func (m *modelDialogCmp) Init() tea.Cmd { + cfg := config.Get() + enabledProviders := getEnabledProviders(cfg) + + modelItems := []util.Model{} + for _, provider := range enabledProviders { + name, ok := ProviderName[provider] + if !ok { + name = string(provider) // Fallback to provider ID if name is not defined + } + modelItems = append(modelItems, commands.NewItemSection(name)) + for _, model := range getModelsForProvider(provider) { + modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model)) + } + } + m.modelList.SetItems(modelItems) + return m.modelList.Init() +} + +func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.wWidth = msg.Width + m.wHeight = msg.Height + return m, m.modelList.SetSize(m.listWidth(), m.listHeight()) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, m.keyMap.Select): + selectedItemInx := m.modelList.SelectedIndex() + if selectedItemInx == list.NoSelection { + return m, nil // No item selected, do nothing + } + items := m.modelList.Items() + selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model) + + return m, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler(ModelSelectedMsg{Model: selectedItem}), + ) + default: + u, cmd := m.modelList.Update(msg) + m.modelList = u.(list.ListModel) + return m, cmd + } + } + return m, nil +} + +func (m *modelDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := m.modelList.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)), + listView.String(), + "", + t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)), + ) + v := tea.NewView(m.style().Render(content)) + if listView.Cursor() != nil { + c := m.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (m *modelDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(m.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (m *modelDialogCmp) listWidth() int { + return defaultWidth - 2 // 4 for padding +} + +func (m *modelDialogCmp) listHeight() int { + listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections + return min(listHeigh, m.wHeight/2) +} + +func GetSelectedModel(cfg *config.Config) models.Model { + agentCfg := cfg.Agents[config.AgentCoder] + selectedModelId := agentCfg.Model + return models.SupportedModels[selectedModelId] +} + +func getEnabledProviders(cfg *config.Config) []models.ModelProvider { + var providers []models.ModelProvider + for providerId, provider := range cfg.Providers { + if !provider.Disabled { + providers = append(providers, providerId) + } + } + + // Sort by provider popularity + slices.SortFunc(providers, func(a, b models.ModelProvider) int { + rA := ProviderPopularity[a] + rB := ProviderPopularity[b] + + // models not included in popularity ranking default to last + if rA == 0 { + rA = 999 + } + if rB == 0 { + rB = 999 + } + return rA - rB + }) + return providers +} + +func getModelsForProvider(provider models.ModelProvider) []models.Model { + var providerModels []models.Model + for _, model := range models.SupportedModels { + if model.Provider == provider { + providerModels = append(providerModels, model) + } + } + + // reverse alphabetical order (if llm naming was consistent latest would appear first) + slices.SortFunc(providerModels, func(a, b models.Model) int { + if a.Name > b.Name { + return -1 + } else if a.Name < b.Name { + return 1 + } + return 0 + }) + + return providerModels +} + +func (m *modelDialogCmp) Position() (int, int) { + row := m.wHeight/4 - 2 // just a bit above the center + col := m.wWidth / 2 + col -= m.width / 2 + return row, col +} + +func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := m.Position() + offset := row + 3 // Border + title + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +func (m *modelDialogCmp) ID() dialogs.DialogID { + return ID +} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index b0921ce47dbb3e3a3e6afafcca7b794b8f07bc05..e64de9b2ccdfd974724f9f12bf8745072df01333 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -63,7 +63,13 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD } } - sessionsList := list.New(list.WithFilterable(true), list.WithFilterPlaceholder("Enter a session name"), list.WithKeyMap(listKeyMap), list.WithItems(items)) + sessionsList := list.New( + list.WithFilterable(true), + list.WithFilterPlaceholder("Enter a session name"), + list.WithKeyMap(listKeyMap), + list.WithItems(items), + list.WithWrapNavigation(true), + ) help := help.New() help.Styles = t.S().Help s := &sessionDialogCmp{ diff --git a/internal/tui/keys.go b/internal/tui/keys.go index bc836d3dbf1f5bd0e88bc02fb4628e1305f9bcd8..af207f8c06bb720eda6047817cebb7bf60551134 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -6,14 +6,13 @@ import ( ) type KeyMap struct { - Logs key.Binding - Quit key.Binding - Help key.Binding - SwitchSession key.Binding - Commands key.Binding - FilePicker key.Binding - Models key.Binding - SwitchTheme key.Binding + Logs key.Binding + Quit key.Binding + Help key.Binding + Commands key.Binding + FilePicker key.Binding + Models key.Binding + SwitchTheme key.Binding } func DefaultKeyMap() KeyMap { @@ -33,11 +32,6 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+?", "toggle help"), ), - SwitchSession: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "switch session"), - ), - Commands: key.NewBinding( key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands"), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 56e439c686d5f0a07775788abb25be55c182e5a5..5bd000b470bc9ce31f0ab3e9d2b6c08e49cf8118 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -14,6 +14,7 @@ import ( "github.com/opencode-ai/opencode/internal/tui/components/core" "github.com/opencode-ai/opencode/internal/tui/components/dialogs" "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" + "github.com/opencode-ai/opencode/internal/tui/components/dialogs/models" "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit" "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions" "github.com/opencode-ai/opencode/internal/tui/layout" @@ -116,6 +117,20 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, pageCmd) } return a, tea.Batch(cmds...) + // Commands + case commands.SwitchSessionsMsg: + return a, func() tea.Msg { + allSessions, _ := a.app.Sessions.List(context.Background()) + return dialogs.OpenDialogMsg{ + Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), + } + } + case commands.SwitchModelMsg: + return a, util.CmdHandler( + dialogs.OpenDialogMsg{ + Model: models.NewModelDialogCmp(), + }, + ) case tea.KeyPressMsg: return a, a.handleKeyPressMsg(msg) } @@ -182,13 +197,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return util.CmdHandler(dialogs.OpenDialogMsg{ Model: commands.NewCommandDialog(), }) - case key.Matches(msg, a.keyMap.SwitchSession): - return func() tea.Msg { - allSessions, _ := a.app.Sessions.List(context.Background()) - return dialogs.OpenDialogMsg{ - Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), - } - } // Page navigation case key.Matches(msg, a.keyMap.Logs): return a.moveToPage(page.LogsPage) diff --git a/todos.md b/todos.md index 635dc0703583fa8ec87883d7b34da04241c1c3ef..e7acfc4c3b73d8073a792c744fc452454fd41193 100644 --- a/todos.md +++ b/todos.md @@ -9,7 +9,7 @@ ## Dialogs -- [ ] Move sessions and modal dialog to the commands +- [x] Cleanup Commands - [x] Sessions dialog -- [ ] Commands - [ ] Models +- [~] Move sessions and model dialog to the commands