commands,model selector

Kujtim Hoxha created

Change summary

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 --------
internal/tui/components/dialogs/commands/commands.go | 141 +++-
internal/tui/components/dialogs/commands/item.go     |  25 
internal/tui/components/dialogs/commands/keys.go     |  18 
internal/tui/components/dialogs/models/keys.go       |  56 ++
internal/tui/components/dialogs/models/models.go     | 261 +++++++++
internal/tui/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(-)

Detailed changes

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",

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)

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

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) {

internal/tui/components/dialog/models.go 🔗

@@ -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{}
-}

internal/tui/components/dialog/session.go 🔗

@@ -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: "",
-	}
-}

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{}
+				}
+			},
+		},
 	}
 }
 

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) {

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"),
+		),
 	}
 }
 

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"),
+		),
+	}
+}

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
+}

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{

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"),

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)

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