on main help sign in
commands,model selector
commitf230c316d2ec33c44e53ed6dac10074cce7e25d9 authorKujtim Hoxha <kujtimii.h@gmail.com> dateTue 3 Jun 2025, 11:41 (1 year ago) parente5ba0c3
15 files changed+523−740
internal/llm/models/models.go+1−37 internal/tui/components/core/helpers.go+4−3 internal/tui/components/core/list/list.go+36−9 internal/tui/components/dialog/filepicker.go+5−6 internal/tui/components/dialog/models.go−374 internal/tui/components/dialog/session.go−233 internal/tui/components/dialogs/commands/commands.go+105−36 internal/tui/components/dialogs/commands/item.go+9−16 internal/tui/components/dialogs/commands/keys.go+15−3 internal/tui/components/dialogs/models/keys.go+56 internal/tui/components/dialogs/models/models.go+261 internal/tui/components/dialogs/sessions/sessions.go+7−1 internal/tui/keys.go+7−13 internal/tui/tui.go+15−7 todos.md+2−2
internal/llm/models/models.go+1−37
@@ -34,44 +34,8 @@ const (
3434 ProviderMock ModelProvider = "__mock"
3535)
3636
37−// Providers in order of popularity
38−var ProviderPopularity = map[ModelProvider]int{
39− ProviderAnthropic: 1,
40− ProviderOpenAI: 2,
41− ProviderGemini: 3,
42− ProviderGROQ: 4,
43− ProviderOpenRouter: 5,
44− ProviderBedrock: 6,
45− ProviderAzure: 7,
46− ProviderVertexAI: 8,
47−}
48−
4937var SupportedModels = map[ModelID]Model{
50− //
51− // // GEMINI
52− // GEMINI25: {
53− // ID: GEMINI25,
54− // Name: "Gemini 2.5 Pro",
55− // Provider: ProviderGemini,
56− // APIModel: "gemini-2.5-pro-exp-03-25",
57− // CostPer1MIn: 0,
58− // CostPer1MInCached: 0,
59− // CostPer1MOutCached: 0,
60− // CostPer1MOut: 0,
61− // },
62− //
63− // GRMINI20Flash: {
64− // ID: GRMINI20Flash,
65− // Name: "Gemini 2.0 Flash",
66− // Provider: ProviderGemini,
67− // APIModel: "gemini-2.0-flash",
68− // CostPer1MIn: 0.1,
69− // CostPer1MInCached: 0,
70− // CostPer1MOutCached: 0.025,
71− // CostPer1MOut: 0.4,
72− // },
73− //
74− // // Bedrock
38+ // Bedrock
7539 BedrockClaude37Sonnet: {
7640 ID: BedrockClaude37Sonnet,
7741 Name: "Bedrock: Claude 3.7 Sonnet",
internal/tui/components/core/helpers.go+4−3
@@ -12,10 +12,11 @@ import (
1212func Section(text string, width int) string {
1313 t := styles.CurrentTheme()
1414 char := "─"
15− length := len(text) + 1
15+ length := lipgloss.Width(text) + 1
1616 remainingWidth := width - length
17+ lineStyle := t.S().Base.Foreground(t.Border)
1718 if remainingWidth > 0 {
18− text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
19+ text = text + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
1920 }
2021 return text
2122}
@@ -23,7 +24,7 @@ func Section(text string, width int) string {
2324func Title(title string, width int) string {
2425 t := styles.CurrentTheme()
2526 char := "╱"
26− length := len(title) + 1
27+ length := lipgloss.Width(title) + 1
2728 remainingWidth := width - length
2829 lineStyle := t.S().Base.Foreground(t.Primary)
2930 titleStyle := t.S().Base.Foreground(t.Primary)
internal/tui/components/core/list/list.go+36−9
@@ -133,11 +133,13 @@ type model struct {
133133 allItems []util.Model // The actual list items
134134 gapSize int // Number of empty lines between items
135135 padding []int // Padding around the list content
136+ wrapNavigation bool // Whether to wrap navigation at the ends
136137
137138 filterable bool // Whether items can be filtered
138139 filterPlaceholder string // Placeholder text for filter input
139140 filteredItems []util.Model // Filtered items based on current search
140141 input textinput.Model // Input field for filtering items
142+ inputStyle lipgloss.Style // Style for the input field
141143 hideFilterInput bool // Whether to hide the filter input field
142144 currentSearch string // Current search term for filtering
143145}
@@ -204,10 +206,26 @@ func WithFilterPlaceholder(placeholder string) listOptions {
204206 }
205207}
206208
209+// WithInputStyle sets the style for the filter input field.
210+func WithInputStyle(style lipgloss.Style) listOptions {
211+ return func(m *model) {
212+ m.inputStyle = style
213+ }
214+}
215+
216+// WithWrapNavigation enables wrapping navigation at the ends of the list.
217+func WithWrapNavigation(wrap bool) listOptions {
218+ return func(m *model) {
219+ m.wrapNavigation = wrap
220+ }
221+}
222+
207223// New creates a new list model with the specified options.
208224// The list starts with no items selected and requires SetItems to be called
209225// or items to be provided via WithItems option.
210226func New(opts ...listOptions) ListModel {
227+ t := styles.CurrentTheme()
228+
211229 m := &model{
212230 help: help.New(),
213231 keyMap: DefaultKeyMap(),
@@ -218,6 +236,7 @@ func New(opts ...listOptions) ListModel {
218236 padding: []int{},
219237 selectionState: selectionState{selectedIndex: NoSelection},
220238 filterPlaceholder: "Type to filter...",
239+ inputStyle: t.S().Base.Padding(0, 1, 1, 1),
221240 }
222241 for _, opt := range opts {
223242 opt(m)
@@ -281,7 +300,7 @@ func (m *model) View() tea.View {
281300 if m.filterable && !m.hideFilterInput {
282301 content = lipgloss.JoinVertical(
283302 lipgloss.Left,
284− m.inputStyle().Render(m.input.View()),
303+ m.inputStyle.Render(m.input.View()),
285304 content,
286305 )
287306 }
@@ -400,7 +419,7 @@ func (m *model) renderVisibleForward() {
400419 renderer := &forwardRenderer{
401420 model: m,
402421 start: 0,
403− cutoff: m.viewState.offset + m.listHeight(),
422+ cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2, // We render a bit more so we make sure we have smooth movementsd
404423 items: m.filteredItems,
405424 realIdx: m.renderState.lastIndex,
406425 }
@@ -420,7 +439,7 @@ func (m *model) renderVisibleReverse() {
420439 renderer := &reverseRenderer{
421440 model: m,
422441 start: 0,
423− cutoff: m.viewState.offset + m.listHeight(),
442+ cutoff: m.viewState.offset + m.listHeight() + m.listHeight()/2,
424443 items: m.filteredItems,
425444 realIdx: m.renderState.lastIndex,
426445 }
@@ -567,6 +586,10 @@ func (r *reverseRenderer) renderItemLines(item util.Model) []string {
567586// Handles focus management and ensures the selected item remains visible.
568587// Skips section headers during navigation.
569588func (m *model) selectPreviousItem() tea.Cmd {
589+ if m.selectionState.selectedIndex == m.findFirstSelectableItem() && m.wrapNavigation {
590+ // If at the beginning and wrapping is enabled, go to the last item
591+ return m.goToBottom()
592+ }
570593 if m.selectionState.selectedIndex <= 0 {
571594 return nil
572595 }
@@ -580,8 +603,9 @@ func (m *model) selectPreviousItem() tea.Cmd {
580603 }
581604
582605 // If we went past the beginning, stay at the first non-header item
583− if m.selectionState.selectedIndex < 0 {
584− m.selectionState.selectedIndex = m.findFirstSelectableItem()
606+ if m.selectionState.selectedIndex <= 0 {
607+ cmds = append(cmds, m.goToTop()) // Ensure we scroll to the top if needed
608+ return tea.Batch(cmds...)
585609 }
586610
587611 cmds = append(cmds, m.focusSelected())
@@ -593,6 +617,10 @@ func (m *model) selectPreviousItem() tea.Cmd {
593617// Handles focus management and ensures the selected item remains visible.
594618// Skips section headers during navigation.
595619func (m *model) selectNextItem() tea.Cmd {
620+ if m.selectionState.selectedIndex >= m.findLastSelectableItem() && m.wrapNavigation {
621+ // If at the end and wrapping is enabled, go to the first item
622+ return m.goToTop()
623+ }
596624 if m.selectionState.selectedIndex >= len(m.filteredItems)-1 || m.selectionState.selectedIndex < 0 {
597625 return nil
598626 }
@@ -1008,6 +1036,9 @@ func (m *model) listHeight() int {
10081036 case 3, 4:
10091037 height -= m.padding[0] + m.padding[2]
10101038 }
1039+ if m.filterable && !m.hideFilterInput {
1040+ height -= lipgloss.Height(m.inputStyle.Render("dummy"))
1041+ }
10111042 return max(0, height)
10121043}
10131044
@@ -1107,10 +1138,6 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
11071138 return tea.Batch(cmds...)
11081139}
11091140
1110−func (c *model) inputStyle() lipgloss.Style {
1111− return styles.BaseStyle().Padding(0, 1, 1, 1)
1112−}
1113−
11141141// section represents a group of items under a section header.
11151142type section struct {
11161143 header SectionHeader
internal/tui/components/dialog/filepicker.go+5−6
@@ -15,7 +15,6 @@ import (
1515 tea "github.com/charmbracelet/bubbletea/v2"
1616 "github.com/charmbracelet/lipgloss/v2"
1717 "github.com/opencode-ai/opencode/internal/app"
18− "github.com/opencode-ai/opencode/internal/config"
1918 "github.com/opencode-ai/opencode/internal/logging"
2019 "github.com/opencode-ai/opencode/internal/message"
2120 "github.com/opencode-ai/opencode/internal/tui/image"
@@ -222,11 +221,11 @@ func (f *filepickerCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
222221}
223222
224223func (f *filepickerCmp) addAttachmentToMessage() (tea.Model, tea.Cmd) {
225− modeInfo := GetSelectedModel(config.Get())
226− if !modeInfo.SupportsAttachments {
227− logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
228− return f, nil
229− }
224+ // modeInfo := GetSelectedModel(config.Get())
225+ // if !modeInfo.SupportsAttachments {
226+ // logging.ErrorPersist(fmt.Sprintf("Model %s doesn't support attachments", modeInfo.Name))
227+ // return f, nil
228+ // }
230229
231230 selectedFilePath := f.selectedFile
232231 if !isExtSupported(selectedFilePath) {
internal/tui/components/dialog/models.godeleted−374
@@ -1,374 +0,0 @@
1−package dialog
2−
3−import (
4− "fmt"
5− "slices"
6− "strings"
7−
8− "github.com/charmbracelet/bubbles/v2/key"
9− tea "github.com/charmbracelet/bubbletea/v2"
10− "github.com/charmbracelet/lipgloss/v2"
11− "github.com/opencode-ai/opencode/internal/config"
12− "github.com/opencode-ai/opencode/internal/llm/models"
13− "github.com/opencode-ai/opencode/internal/tui/layout"
14− "github.com/opencode-ai/opencode/internal/tui/styles"
15− "github.com/opencode-ai/opencode/internal/tui/theme"
16− "github.com/opencode-ai/opencode/internal/tui/util"
17−)
18−
19−const (
20− numVisibleModels = 10
21− maxDialogWidth = 40
22−)
23−
24−// ModelSelectedMsg is sent when a model is selected
25−type ModelSelectedMsg struct {
26− Model models.Model
27−}
28−
29−// CloseModelDialogMsg is sent when a model is selected
30−type CloseModelDialogMsg struct{}
31−
32−// ModelDialog interface for the model selection dialog
33−type ModelDialog interface {
34− util.Model
35− layout.Bindings
36−}
37−
38−type modelDialogCmp struct {
39− models []models.Model
40− provider models.ModelProvider
41− availableProviders []models.ModelProvider
42−
43− selectedIdx int
44− width int
45− height int
46− scrollOffset int
47− hScrollOffset int
48− hScrollPossible bool
49−}
50−
51−type modelKeyMap struct {
52− Up key.Binding
53− Down key.Binding
54− Left key.Binding
55− Right key.Binding
56− Enter key.Binding
57− Escape key.Binding
58− J key.Binding
59− K key.Binding
60− H key.Binding
61− L key.Binding
62−}
63−
64−var modelKeys = modelKeyMap{
65− Up: key.NewBinding(
66− key.WithKeys("up"),
67− key.WithHelp("↑", "previous model"),
68− ),
69− Down: key.NewBinding(
70− key.WithKeys("down"),
71− key.WithHelp("↓", "next model"),
72− ),
73− Left: key.NewBinding(
74− key.WithKeys("left"),
75− key.WithHelp("←", "scroll left"),
76− ),
77− Right: key.NewBinding(
78− key.WithKeys("right"),
79− key.WithHelp("→", "scroll right"),
80− ),
81− Enter: key.NewBinding(
82− key.WithKeys("enter"),
83− key.WithHelp("enter", "select model"),
84− ),
85− Escape: key.NewBinding(
86− key.WithKeys("esc"),
87− key.WithHelp("esc", "close"),
88− ),
89− J: key.NewBinding(
90− key.WithKeys("j"),
91− key.WithHelp("j", "next model"),
92− ),
93− K: key.NewBinding(
94− key.WithKeys("k"),
95− key.WithHelp("k", "previous model"),
96− ),
97− H: key.NewBinding(
98− key.WithKeys("h"),
99− key.WithHelp("h", "scroll left"),
100− ),
101− L: key.NewBinding(
102− key.WithKeys("l"),
103− key.WithHelp("l", "scroll right"),
104− ),
105−}
106−
107−func (m *modelDialogCmp) Init() tea.Cmd {
108− m.setupModels()
109− return nil
110−}
111−
112−func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113− switch msg := msg.(type) {
114− case tea.KeyPressMsg:
115− switch {
116− case key.Matches(msg, modelKeys.Up) || key.Matches(msg, modelKeys.K):
117− m.moveSelectionUp()
118− case key.Matches(msg, modelKeys.Down) || key.Matches(msg, modelKeys.J):
119− m.moveSelectionDown()
120− case key.Matches(msg, modelKeys.Left) || key.Matches(msg, modelKeys.H):
121− if m.hScrollPossible {
122− m.switchProvider(-1)
123− }
124− case key.Matches(msg, modelKeys.Right) || key.Matches(msg, modelKeys.L):
125− if m.hScrollPossible {
126− m.switchProvider(1)
127− }
128− case key.Matches(msg, modelKeys.Enter):
129− util.ReportInfo(fmt.Sprintf("selected model: %s", m.models[m.selectedIdx].Name))
130− return m, util.CmdHandler(ModelSelectedMsg{Model: m.models[m.selectedIdx]})
131− case key.Matches(msg, modelKeys.Escape):
132− return m, util.CmdHandler(CloseModelDialogMsg{})
133− }
134− case tea.WindowSizeMsg:
135− m.width = msg.Width
136− m.height = msg.Height
137− }
138−
139− return m, nil
140−}
141−
142−// moveSelectionUp moves the selection up or wraps to bottom
143−func (m *modelDialogCmp) moveSelectionUp() {
144− if m.selectedIdx > 0 {
145− m.selectedIdx--
146− } else {
147− m.selectedIdx = len(m.models) - 1
148− m.scrollOffset = max(0, len(m.models)-numVisibleModels)
149− }
150−
151− // Keep selection visible
152− if m.selectedIdx < m.scrollOffset {
153− m.scrollOffset = m.selectedIdx
154− }
155−}
156−
157−// moveSelectionDown moves the selection down or wraps to top
158−func (m *modelDialogCmp) moveSelectionDown() {
159− if m.selectedIdx < len(m.models)-1 {
160− m.selectedIdx++
161− } else {
162− m.selectedIdx = 0
163− m.scrollOffset = 0
164− }
165−
166− // Keep selection visible
167− if m.selectedIdx >= m.scrollOffset+numVisibleModels {
168− m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
169− }
170−}
171−
172−func (m *modelDialogCmp) switchProvider(offset int) {
173− newOffset := m.hScrollOffset + offset
174−
175− // Ensure we stay within bounds
176− if newOffset < 0 {
177− newOffset = len(m.availableProviders) - 1
178− }
179− if newOffset >= len(m.availableProviders) {
180− newOffset = 0
181− }
182−
183− m.hScrollOffset = newOffset
184− m.provider = m.availableProviders[m.hScrollOffset]
185− m.setupModelsForProvider(m.provider)
186−}
187−
188−func (m *modelDialogCmp) View() tea.View {
189− t := theme.CurrentTheme()
190− baseStyle := styles.BaseStyle()
191−
192− // Capitalize first letter of provider name
193− providerName := strings.ToUpper(string(m.provider)[:1]) + string(m.provider[1:])
194− title := baseStyle.
195− Foreground(t.Primary()).
196− Bold(true).
197− Width(maxDialogWidth).
198− Padding(0, 0, 1).
199− Render(fmt.Sprintf("Select %s Model", providerName))
200−
201− // Render visible models
202− endIdx := min(m.scrollOffset+numVisibleModels, len(m.models))
203− modelItems := make([]string, 0, endIdx-m.scrollOffset)
204−
205− for i := m.scrollOffset; i < endIdx; i++ {
206− itemStyle := baseStyle.Width(maxDialogWidth)
207− if i == m.selectedIdx {
208− itemStyle = itemStyle.Background(t.Primary()).
209− Foreground(t.Background()).Bold(true)
210− }
211− modelItems = append(modelItems, itemStyle.Render(m.models[i].Name))
212− }
213−
214− scrollIndicator := m.getScrollIndicators(maxDialogWidth)
215−
216− content := lipgloss.JoinVertical(
217− lipgloss.Left,
218− title,
219− baseStyle.Width(maxDialogWidth).Render(lipgloss.JoinVertical(lipgloss.Left, modelItems...)),
220− scrollIndicator,
221− )
222−
223− return tea.NewView(
224− baseStyle.Padding(1, 2).
225− Border(lipgloss.RoundedBorder()).
226− BorderBackground(t.Background()).
227− BorderForeground(t.TextMuted()).
228− Width(lipgloss.Width(content) + 4).
229− Render(content),
230− )
231−}
232−
233−func (m *modelDialogCmp) getScrollIndicators(maxWidth int) string {
234− var indicator string
235−
236− if len(m.models) > numVisibleModels {
237− if m.scrollOffset > 0 {
238− indicator += "↑ "
239− }
240− if m.scrollOffset+numVisibleModels < len(m.models) {
241− indicator += "↓ "
242− }
243− }
244−
245− if m.hScrollPossible {
246− if m.hScrollOffset > 0 {
247− indicator = "← " + indicator
248− }
249− if m.hScrollOffset < len(m.availableProviders)-1 {
250− indicator += "→"
251− }
252− }
253−
254− if indicator == "" {
255− return ""
256− }
257−
258− t := theme.CurrentTheme()
259− baseStyle := styles.BaseStyle()
260−
261− return baseStyle.
262− Foreground(t.Primary()).
263− Width(maxWidth).
264− Align(lipgloss.Right).
265− Bold(true).
266− Render(indicator)
267−}
268−
269−func (m *modelDialogCmp) BindingKeys() []key.Binding {
270− return layout.KeyMapToSlice(modelKeys)
271−}
272−
273−func (m *modelDialogCmp) setupModels() {
274− cfg := config.Get()
275− modelInfo := GetSelectedModel(cfg)
276− m.availableProviders = getEnabledProviders(cfg)
277− m.hScrollPossible = len(m.availableProviders) > 1
278−
279− m.provider = modelInfo.Provider
280− m.hScrollOffset = findProviderIndex(m.availableProviders, m.provider)
281−
282− m.setupModelsForProvider(m.provider)
283−}
284−
285−func GetSelectedModel(cfg *config.Config) models.Model {
286− agentCfg := cfg.Agents[config.AgentCoder]
287− selectedModelId := agentCfg.Model
288− return models.SupportedModels[selectedModelId]
289−}
290−
291−func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
292− var providers []models.ModelProvider
293− for providerId, provider := range cfg.Providers {
294− if !provider.Disabled {
295− providers = append(providers, providerId)
296− }
297− }
298−
299− // Sort by provider popularity
300− slices.SortFunc(providers, func(a, b models.ModelProvider) int {
301− rA := models.ProviderPopularity[a]
302− rB := models.ProviderPopularity[b]
303−
304− // models not included in popularity ranking default to last
305− if rA == 0 {
306− rA = 999
307− }
308− if rB == 0 {
309− rB = 999
310− }
311− return rA - rB
312− })
313− return providers
314−}
315−
316−// findProviderIndex returns the index of the provider in the list, or -1 if not found
317−func findProviderIndex(providers []models.ModelProvider, provider models.ModelProvider) int {
318− for i, p := range providers {
319− if p == provider {
320− return i
321− }
322− }
323− return -1
324−}
325−
326−func (m *modelDialogCmp) setupModelsForProvider(provider models.ModelProvider) {
327− cfg := config.Get()
328− agentCfg := cfg.Agents[config.AgentCoder]
329− selectedModelId := agentCfg.Model
330−
331− m.provider = provider
332− m.models = getModelsForProvider(provider)
333− m.selectedIdx = 0
334− m.scrollOffset = 0
335−
336− // Try to select the current model if it belongs to this provider
337− if provider == models.SupportedModels[selectedModelId].Provider {
338− for i, model := range m.models {
339− if model.ID == selectedModelId {
340− m.selectedIdx = i
341− // Adjust scroll position to keep selected model visible
342− if m.selectedIdx >= numVisibleModels {
343− m.scrollOffset = m.selectedIdx - (numVisibleModels - 1)
344− }
345− break
346− }
347− }
348− }
349−}
350−
351−func getModelsForProvider(provider models.ModelProvider) []models.Model {
352− var providerModels []models.Model
353− for _, model := range models.SupportedModels {
354− if model.Provider == provider {
355− providerModels = append(providerModels, model)
356− }
357− }
358−
359− // reverse alphabetical order (if llm naming was consistent latest would appear first)
360− slices.SortFunc(providerModels, func(a, b models.Model) int {
361− if a.Name > b.Name {
362− return -1
363− } else if a.Name < b.Name {
364− return 1
365− }
366− return 0
367− })
368−
369− return providerModels
370−}
371−
372−func NewModelDialogCmp() ModelDialog {
373− return &modelDialogCmp{}
374−}
internal/tui/components/dialog/session.godeleted−233
@@ -1,233 +0,0 @@
1−package dialog
2−
3−import (
4− "github.com/charmbracelet/bubbles/v2/key"
5− tea "github.com/charmbracelet/bubbletea/v2"
6− "github.com/charmbracelet/lipgloss/v2"
7− "github.com/opencode-ai/opencode/internal/session"
8− "github.com/opencode-ai/opencode/internal/tui/layout"
9− "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11− "github.com/opencode-ai/opencode/internal/tui/util"
12−)
13−
14−// SessionSelectedMsg is sent when a session is selected
15−type SessionSelectedMsg struct {
16− Session session.Session
17−}
18−
19−// CloseSessionDialogMsg is sent when the session dialog is closed
20−type CloseSessionDialogMsg struct{}
21−
22−// SessionDialog interface for the session switching dialog
23−type SessionDialog interface {
24− util.Model
25− layout.Bindings
26− SetSessions(sessions []session.Session)
27− SetSelectedSession(sessionID string)
28−}
29−
30−type sessionDialogCmp struct {
31− sessions []session.Session
32− selectedIdx int
33− width int
34− height int
35− selectedSessionID string
36−}
37−
38−type sessionKeyMap struct {
39− Up key.Binding
40− Down key.Binding
41− Enter key.Binding
42− Escape key.Binding
43− J key.Binding
44− K key.Binding
45−}
46−
47−var sessionKeys = sessionKeyMap{
48− Up: key.NewBinding(
49− key.WithKeys("up"),
50− key.WithHelp("↑", "previous session"),
51− ),
52− Down: key.NewBinding(
53− key.WithKeys("down"),
54− key.WithHelp("↓", "next session"),
55− ),
56− Enter: key.NewBinding(
57− key.WithKeys("enter"),
58− key.WithHelp("enter", "select session"),
59− ),
60− Escape: key.NewBinding(
61− key.WithKeys("esc"),
62− key.WithHelp("esc", "close"),
63− ),
64− J: key.NewBinding(
65− key.WithKeys("j"),
66− key.WithHelp("j", "next session"),
67− ),
68− K: key.NewBinding(
69− key.WithKeys("k"),
70− key.WithHelp("k", "previous session"),
71− ),
72−}
73−
74−func (s *sessionDialogCmp) Init() tea.Cmd {
75− return nil
76−}
77−
78−func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
79− switch msg := msg.(type) {
80− case tea.KeyPressMsg:
81− switch {
82− case key.Matches(msg, sessionKeys.Up) || key.Matches(msg, sessionKeys.K):
83− if s.selectedIdx > 0 {
84− s.selectedIdx--
85− }
86− return s, nil
87− case key.Matches(msg, sessionKeys.Down) || key.Matches(msg, sessionKeys.J):
88− if s.selectedIdx < len(s.sessions)-1 {
89− s.selectedIdx++
90− }
91− return s, nil
92− case key.Matches(msg, sessionKeys.Enter):
93− if len(s.sessions) > 0 {
94− return s, util.CmdHandler(SessionSelectedMsg{
95− Session: s.sessions[s.selectedIdx],
96− })
97− }
98− case key.Matches(msg, sessionKeys.Escape):
99− return s, util.CmdHandler(CloseSessionDialogMsg{})
100− }
101− case tea.WindowSizeMsg:
102− s.width = msg.Width
103− s.height = msg.Height
104− }
105− return s, nil
106−}
107−
108−func (s *sessionDialogCmp) View() tea.View {
109− t := theme.CurrentTheme()
110− baseStyle := styles.BaseStyle()
111−
112− if len(s.sessions) == 0 {
113− return tea.NewView(
114− baseStyle.Padding(1, 2).
115− Border(lipgloss.RoundedBorder()).
116− BorderBackground(t.Background()).
117− BorderForeground(t.TextMuted()).
118− Width(40).
119− Render("No sessions available"),
120− )
121− }
122−
123− // Calculate max width needed for session titles
124− maxWidth := 40 // Minimum width
125− for _, sess := range s.sessions {
126− if len(sess.Title) > maxWidth-4 { // Account for padding
127− maxWidth = len(sess.Title) + 4
128− }
129− }
130−
131− maxWidth = max(30, min(maxWidth, s.width-15)) // Limit width to avoid overflow
132−
133− // Limit height to avoid taking up too much screen space
134− maxVisibleSessions := min(10, len(s.sessions))
135−
136− // Build the session list
137− sessionItems := make([]string, 0, maxVisibleSessions)
138− startIdx := 0
139−
140− // If we have more sessions than can be displayed, adjust the start index
141− if len(s.sessions) > maxVisibleSessions {
142− // Center the selected item when possible
143− halfVisible := maxVisibleSessions / 2
144− if s.selectedIdx >= halfVisible && s.selectedIdx < len(s.sessions)-halfVisible {
145− startIdx = s.selectedIdx - halfVisible
146− } else if s.selectedIdx >= len(s.sessions)-halfVisible {
147− startIdx = len(s.sessions) - maxVisibleSessions
148− }
149− }
150−
151− endIdx := min(startIdx+maxVisibleSessions, len(s.sessions))
152−
153− for i := startIdx; i < endIdx; i++ {
154− sess := s.sessions[i]
155− itemStyle := baseStyle.Width(maxWidth)
156−
157− if i == s.selectedIdx {
158− itemStyle = itemStyle.
159− Background(t.Primary()).
160− Foreground(t.Background()).
161− Bold(true)
162− }
163−
164− sessionItems = append(sessionItems, itemStyle.Padding(0, 1).Render(sess.Title))
165− }
166−
167− title := baseStyle.
168− Foreground(t.Primary()).
169− Bold(true).
170− Width(maxWidth).
171− Padding(0, 1).
172− Render("Switch Session")
173−
174− content := lipgloss.JoinVertical(
175− lipgloss.Left,
176− title,
177− baseStyle.Width(maxWidth).Render(""),
178− baseStyle.Width(maxWidth).Render(lipgloss.JoinVertical(lipgloss.Left, sessionItems...)),
179− baseStyle.Width(maxWidth).Render(""),
180− )
181−
182− return tea.NewView(
183− baseStyle.Padding(1, 2).
184− Border(lipgloss.RoundedBorder()).
185− BorderBackground(t.Background()).
186− BorderForeground(t.TextMuted()).
187− Render(content),
188− )
189−}
190−
191−func (s *sessionDialogCmp) BindingKeys() []key.Binding {
192− return layout.KeyMapToSlice(sessionKeys)
193−}
194−
195−func (s *sessionDialogCmp) SetSessions(sessions []session.Session) {
196− s.sessions = sessions
197−
198− // If we have a selected session ID, find its index
199− if s.selectedSessionID != "" {
200− for i, sess := range sessions {
201− if sess.ID == s.selectedSessionID {
202− s.selectedIdx = i
203− return
204− }
205− }
206− }
207−
208− // Default to first session if selected not found
209− s.selectedIdx = 0
210−}
211−
212−func (s *sessionDialogCmp) SetSelectedSession(sessionID string) {
213− s.selectedSessionID = sessionID
214−
215− // Update the selected index if sessions are already loaded
216− if len(s.sessions) > 0 {
217− for i, sess := range s.sessions {
218− if sess.ID == sessionID {
219− s.selectedIdx = i
220− return
221− }
222− }
223− }
224−}
225−
226−// NewSessionDialogCmp creates a new session switching dialog
227−func NewSessionDialogCmp() SessionDialog {
228− return &sessionDialogCmp{
229− sessions: []session.Session{},
230− selectedIdx: 0,
231− selectedSessionID: "",
232− }
233−}
internal/tui/components/dialogs/commands/commands.go+105−36
@@ -1,23 +1,29 @@
11package commands
22
33import (
4+ "github.com/charmbracelet/bubbles/v2/help"
45 "github.com/charmbracelet/bubbles/v2/key"
56 tea "github.com/charmbracelet/bubbletea/v2"
67 "github.com/charmbracelet/lipgloss/v2"
78
89 "github.com/opencode-ai/opencode/internal/tui/components/chat"
910 "github.com/opencode-ai/opencode/internal/tui/components/completions"
11+ "github.com/opencode-ai/opencode/internal/tui/components/core"
1012 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
1113 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1214 "github.com/opencode-ai/opencode/internal/tui/styles"
13− "github.com/opencode-ai/opencode/internal/tui/theme"
1415 "github.com/opencode-ai/opencode/internal/tui/util"
1516)
1617
1718const (
1819 commandsDialogID dialogs.DialogID = "commands"
1920
20− defaultWidth int = 60
21+ defaultWidth int = 70
22+)
23+
24+const (
25+ SystemCommands int = iota
26+ UserCommands
2127)
2228
2329// Command represents a command that can be executed
@@ -38,11 +44,18 @@ type commandDialogCmp struct {
3844 wWidth int // Width of the terminal window
3945 wHeight int // Height of the terminal window
4046
41− commandList list.ListModel
42− commands []Command
43− keyMap CommandsDialogKeyMap
47+ commandList list.ListModel
48+ keyMap CommandsDialogKeyMap
49+ help help.Model
50+ commandType int // SystemCommands or UserCommands
51+ userCommands []Command // User-defined commands
4452}
4553
54+type (
55+ SwitchSessionsMsg struct{}
56+ SwitchModelMsg struct{}
57+)
58+
4659func NewCommandDialog() CommandsDialog {
4760 listKeyMap := list.DefaultKeyMap()
4861 keyMap := DefaultCommandsDialogKeyMap()
@@ -59,11 +72,20 @@ func NewCommandDialog() CommandsDialog {
5972 listKeyMap.DownOneItem = keyMap.Next
6073 listKeyMap.UpOneItem = keyMap.Previous
6174
62− commandList := list.New(list.WithFilterable(true), list.WithKeyMap(listKeyMap))
75+ t := styles.CurrentTheme()
76+ commandList := list.New(
77+ list.WithFilterable(true),
78+ list.WithKeyMap(listKeyMap),
79+ list.WithWrapNavigation(true),
80+ )
81+ help := help.New()
82+ help.Styles = t.S().Help
6383 return &commandDialogCmp{
6484 commandList: commandList,
6585 width: defaultWidth,
6686 keyMap: DefaultCommandsDialogKeyMap(),
87+ help: help,
88+ commandType: SystemCommands,
6789 }
6890}
6991
@@ -72,24 +94,9 @@ func (c *commandDialogCmp) Init() tea.Cmd {
7294 if err != nil {
7395 return util.ReportError(err)
7496 }
75− c.commands = commands
76−
77− commandItems := []util.Model{}
78− if len(commands) > 0 {
79− commandItems = append(commandItems, NewItemSection("Custom Commands"))
80− for _, cmd := range commands {
81− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
82− }
83− }
84−
85− commandItems = append(commandItems, NewItemSection("Default"))
8697
87− for _, cmd := range c.defaultCommands() {
88− c.commands = append(c.commands, cmd)
89− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
90− }
91−
92− c.commandList.SetItems(commandItems)
98+ c.userCommands = commands
99+ c.SetCommandType(c.commandType)
93100 return c.commandList.Init()
94101}
95102
@@ -112,6 +119,13 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
112119 util.CmdHandler(dialogs.CloseDialogMsg{}),
113120 selectedItem.Handler(selectedItem),
114121 )
122+ case key.Matches(msg, c.keyMap.Tab):
123+ // Toggle command type between System and User commands
124+ if c.commandType == SystemCommands {
125+ return c, c.SetCommandType(UserCommands)
126+ } else {
127+ return c, c.SetCommandType(SystemCommands)
128+ }
115129 default:
116130 u, cmd := c.commandList.Update(msg)
117131 c.commandList = u.(list.ListModel)
@@ -122,8 +136,17 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
122136}
123137
124138func (c *commandDialogCmp) View() tea.View {
139+ t := styles.CurrentTheme()
125140 listView := c.commandList.View()
126− v := tea.NewView(c.style().Render(listView.String()))
141+ radio := c.commandTypeRadio()
142+ content := lipgloss.JoinVertical(
143+ lipgloss.Left,
144+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Commands", c.width-lipgloss.Width(radio)-5)+" "+radio),
145+ listView.String(),
146+ "",
147+ t.S().Base.Width(c.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(c.help.View(c.keyMap)),
148+ )
149+ v := tea.NewView(c.style().Render(content))
127150 if listView.Cursor() != nil {
128151 c := c.moveCursor(listView.Cursor())
129152 v.SetCursor(c)
@@ -131,8 +154,36 @@ func (c *commandDialogCmp) View() tea.View {
131154 return v
132155}
133156
157+func (c *commandDialogCmp) commandTypeRadio() string {
158+ t := styles.CurrentTheme()
159+ choices := []string{"System", "User"}
160+ iconSelected := "◉"
161+ iconUnselected := "○"
162+ if c.commandType == SystemCommands {
163+ return t.S().Text.Render(iconSelected + " " + choices[0] + " " + iconUnselected + " " + choices[1])
164+ }
165+ return t.S().Text.Render(iconUnselected + " " + choices[0] + " " + iconSelected + " " + choices[1])
166+}
167+
134168func (c *commandDialogCmp) listWidth() int {
135− return defaultWidth - 4 // 4 for padding
169+ return defaultWidth - 2 // 4 for padding
170+}
171+
172+func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
173+ c.commandType = commandType
174+
175+ var commands []Command
176+ if c.commandType == SystemCommands {
177+ commands = c.defaultCommands()
178+ } else {
179+ commands = c.userCommands
180+ }
181+
182+ commandItems := []util.Model{}
183+ for _, cmd := range commands {
184+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
185+ }
186+ return c.commandList.SetItems(commandItems)
136187}
137188
138189func (c *commandDialogCmp) listHeight() int {
@@ -141,27 +192,25 @@ func (c *commandDialogCmp) listHeight() int {
141192}
142193
143194func (c *commandDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
144− offset := 10 + 1
195+ row, col := c.Position()
196+ offset := row + 3
145197 cursor.Y += offset
146− _, col := c.Position()
147198 cursor.X = cursor.X + col + 2
148199 return cursor
149200}
150201
151202func (c *commandDialogCmp) style() lipgloss.Style {
152− t := theme.CurrentTheme()
153− return styles.BaseStyle().
203+ t := styles.CurrentTheme()
204+ return t.S().Base.
154205 Width(c.width).
155− Padding(0, 1, 1, 1).
156206 Border(lipgloss.RoundedBorder()).
157− BorderBackground(t.Background()).
158− BorderForeground(t.TextMuted())
207+ BorderForeground(t.BorderFocus)
159208}
160209
161−func (q *commandDialogCmp) Position() (int, int) {
162− row := 10
163− col := q.wWidth / 2
164− col -= q.width / 2
210+func (c *commandDialogCmp) Position() (int, int) {
211+ row := c.wHeight/4 - 2 // just a bit above the center
212+ col := c.wWidth / 2
213+ col -= c.width / 2
165214 return row, col
166215}
167216
@@ -197,6 +246,26 @@ func (c *commandDialogCmp) defaultCommands() []Command {
197246 }
198247 },
199248 },
249+ {
250+ ID: "switch_session",
251+ Title: "Switch Session",
252+ Description: "Switch to a different session",
253+ Handler: func(cmd Command) tea.Cmd {
254+ return func() tea.Msg {
255+ return SwitchSessionsMsg{}
256+ }
257+ },
258+ },
259+ {
260+ ID: "switch_model",
261+ Title: "Switch Model",
262+ Description: "Switch to a different model",
263+ Handler: func(cmd Command) tea.Cmd {
264+ return func() tea.Msg {
265+ return SwitchModelMsg{}
266+ }
267+ },
268+ },
200269 }
201270}
202271
internal/tui/components/dialogs/commands/item.go+9−16
@@ -1,15 +1,12 @@
11package commands
22
33import (
4− "strings"
5−
64 tea "github.com/charmbracelet/bubbletea/v2"
7− "github.com/charmbracelet/lipgloss/v2"
85 "github.com/charmbracelet/x/ansi"
6+ "github.com/opencode-ai/opencode/internal/tui/components/core"
97 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
108 "github.com/opencode-ai/opencode/internal/tui/layout"
119 "github.com/opencode-ai/opencode/internal/tui/styles"
12− "github.com/opencode-ai/opencode/internal/tui/theme"
1310 "github.com/opencode-ai/opencode/internal/tui/util"
1411)
1512
@@ -19,8 +16,9 @@ type ItemSection interface {
1916 list.SectionHeader
2017}
2118type itemSectionModel struct {
22− width int
23− title string
19+ width int
20+ title string
21+ noPadding bool // No padding for the section header
2422}
2523
2624func NewItemSection(title string) ItemSection {
@@ -38,16 +36,11 @@ func (m *itemSectionModel) Update(tea.Msg) (tea.Model, tea.Cmd) {
3836}
3937
4038func (m *itemSectionModel) View() tea.View {
41− t := theme.CurrentTheme()
42− title := ansi.Truncate(m.title, m.width-1, "…")
43− style := styles.BaseStyle().Padding(1, 0, 0, 0).Width(m.width).Foreground(t.TextMuted()).Bold(true)
44− if len(title) < m.width {
45− remainingWidth := m.width - lipgloss.Width(title)
46− if remainingWidth > 0 {
47− title += " " + strings.Repeat("─", remainingWidth-1)
48− }
49− }
50− return tea.NewView(style.Render(title))
39+ t := styles.CurrentTheme()
40+ title := ansi.Truncate(m.title, m.width-2, "…")
41+ style := t.S().Base.Padding(1, 1, 0, 1)
42+ title = t.S().Muted.Render(title)
43+ return tea.NewView(style.Render(core.Section(title, m.width-2)))
5144}
5245
5346func (m *itemSectionModel) GetSize() (int, int) {
internal/tui/components/dialogs/commands/keys.go+15−3
@@ -9,12 +9,13 @@ type CommandsDialogKeyMap struct {
99 Select key.Binding
1010 Next key.Binding
1111 Previous key.Binding
12+ Tab key.Binding
1213}
1314
1415func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
1516 return CommandsDialogKeyMap{
1617 Select: key.NewBinding(
17− key.WithKeys("enter", "tab", "ctrl+y"),
18+ key.WithKeys("enter", "ctrl+y"),
1819 key.WithHelp("enter", "confirm"),
1920 ),
2021 Next: key.NewBinding(
@@ -25,6 +26,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
2526 key.WithKeys("up", "ctrl+p"),
2627 key.WithHelp("↑", "previous item"),
2728 ),
29+ Tab: key.NewBinding(
30+ key.WithKeys("tab"),
31+ key.WithHelp("tab", "switch selection"),
32+ ),
2833 }
2934}
3035
@@ -42,9 +47,16 @@ func (k CommandsDialogKeyMap) FullHelp() [][]key.Binding {
4247// ShortHelp implements help.KeyMap.
4348func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
4449 return []key.Binding{
50+ k.Tab,
51+ key.NewBinding(
52+ key.WithKeys("down", "up"),
53+ key.WithHelp("↑↓", "choose"),
54+ ),
4555 k.Select,
46− k.Next,
47− k.Previous,
56+ key.NewBinding(
57+ key.WithKeys("esc"),
58+ key.WithHelp("esc", "cancel"),
59+ ),
4860 }
4961}
5062
internal/tui/components/dialogs/models/keys.gonew file+56
@@ -0,0 +1,56 @@
1+package models
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/key"
5+ "github.com/opencode-ai/opencode/internal/tui/layout"
6+)
7+
8+type KeyMap struct {
9+ Select key.Binding
10+ Next key.Binding
11+ Previous key.Binding
12+}
13+
14+func DefaultKeyMap() KeyMap {
15+ return KeyMap{
16+ Select: key.NewBinding(
17+ key.WithKeys("enter", "tab", "ctrl+y"),
18+ key.WithHelp("enter", "confirm"),
19+ ),
20+ Next: key.NewBinding(
21+ key.WithKeys("down", "ctrl+n"),
22+ key.WithHelp("↓", "next item"),
23+ ),
24+ Previous: key.NewBinding(
25+ key.WithKeys("up", "ctrl+p"),
26+ key.WithHelp("↑", "previous item"),
27+ ),
28+ }
29+}
30+
31+// FullHelp implements help.KeyMap.
32+func (k KeyMap) FullHelp() [][]key.Binding {
33+ m := [][]key.Binding{}
34+ slice := layout.KeyMapToSlice(k)
35+ for i := 0; i < len(slice); i += 4 {
36+ end := min(i+4, len(slice))
37+ m = append(m, slice[i:end])
38+ }
39+ return m
40+}
41+
42+// ShortHelp implements help.KeyMap.
43+func (k KeyMap) ShortHelp() []key.Binding {
44+ return []key.Binding{
45+ key.NewBinding(
46+
47+ key.WithKeys("down", "up"),
48+ key.WithHelp("↑↓", "choose"),
49+ ),
50+ k.Select,
51+ key.NewBinding(
52+ key.WithKeys("esc"),
53+ key.WithHelp("esc", "cancel"),
54+ ),
55+ }
56+}
internal/tui/components/dialogs/models/models.gonew file+261
@@ -0,0 +1,261 @@
1+package models
2+
3+import (
4+ "slices"
5+
6+ "github.com/charmbracelet/bubbles/v2/help"
7+ "github.com/charmbracelet/bubbles/v2/key"
8+ tea "github.com/charmbracelet/bubbletea/v2"
9+ "github.com/charmbracelet/lipgloss/v2"
10+ "github.com/opencode-ai/opencode/internal/config"
11+ "github.com/opencode-ai/opencode/internal/llm/models"
12+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
13+ "github.com/opencode-ai/opencode/internal/tui/components/core"
14+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
15+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
16+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
17+ "github.com/opencode-ai/opencode/internal/tui/styles"
18+ "github.com/opencode-ai/opencode/internal/tui/util"
19+)
20+
21+const (
22+ ID dialogs.DialogID = "models"
23+
24+ defaultWidth = 60
25+)
26+
27+// ModelSelectedMsg is sent when a model is selected
28+type ModelSelectedMsg struct {
29+ Model models.Model
30+}
31+
32+// CloseModelDialogMsg is sent when a model is selected
33+type CloseModelDialogMsg struct{}
34+
35+// ModelDialog interface for the model selection dialog
36+type ModelDialog interface {
37+ dialogs.DialogModel
38+}
39+
40+type modelDialogCmp struct {
41+ width int
42+ wWidth int // Width of the terminal window
43+ wHeight int // Height of the terminal window
44+
45+ modelList list.ListModel
46+ keyMap KeyMap
47+ help help.Model
48+}
49+
50+func NewModelDialogCmp() ModelDialog {
51+ listKeyMap := list.DefaultKeyMap()
52+ keyMap := DefaultKeyMap()
53+
54+ listKeyMap.Down.SetEnabled(false)
55+ listKeyMap.Up.SetEnabled(false)
56+ listKeyMap.NDown.SetEnabled(false)
57+ listKeyMap.NUp.SetEnabled(false)
58+ listKeyMap.HalfPageDown.SetEnabled(false)
59+ listKeyMap.HalfPageUp.SetEnabled(false)
60+ listKeyMap.Home.SetEnabled(false)
61+ listKeyMap.End.SetEnabled(false)
62+
63+ listKeyMap.DownOneItem = keyMap.Next
64+ listKeyMap.UpOneItem = keyMap.Previous
65+
66+ t := styles.CurrentTheme()
67+ inputStyle := t.S().Base.Padding(0, 1, 0, 1)
68+ modelList := list.New(
69+ list.WithFilterable(true),
70+ list.WithKeyMap(listKeyMap),
71+ list.WithInputStyle(inputStyle),
72+ list.WithWrapNavigation(true),
73+ )
74+ help := help.New()
75+ help.Styles = t.S().Help
76+
77+ return &modelDialogCmp{
78+ modelList: modelList,
79+ width: defaultWidth,
80+ keyMap: DefaultKeyMap(),
81+ help: help,
82+ }
83+}
84+
85+var ProviderPopularity = map[models.ModelProvider]int{
86+ models.ProviderAnthropic: 1,
87+ models.ProviderOpenAI: 2,
88+ models.ProviderGemini: 3,
89+ models.ProviderGROQ: 4,
90+ models.ProviderOpenRouter: 5,
91+ models.ProviderBedrock: 6,
92+ models.ProviderAzure: 7,
93+ models.ProviderVertexAI: 8,
94+ models.ProviderXAI: 9,
95+}
96+
97+var ProviderName = map[models.ModelProvider]string{
98+ models.ProviderAnthropic: "Anthropic",
99+ models.ProviderOpenAI: "OpenAI",
100+ models.ProviderGemini: "Gemini",
101+ models.ProviderGROQ: "Groq",
102+ models.ProviderOpenRouter: "OpenRouter",
103+ models.ProviderBedrock: "AWS Bedrock",
104+ models.ProviderAzure: "Azure",
105+ models.ProviderVertexAI: "VertexAI",
106+ models.ProviderXAI: "xAI",
107+}
108+
109+func (m *modelDialogCmp) Init() tea.Cmd {
110+ cfg := config.Get()
111+ enabledProviders := getEnabledProviders(cfg)
112+
113+ modelItems := []util.Model{}
114+ for _, provider := range enabledProviders {
115+ name, ok := ProviderName[provider]
116+ if !ok {
117+ name = string(provider) // Fallback to provider ID if name is not defined
118+ }
119+ modelItems = append(modelItems, commands.NewItemSection(name))
120+ for _, model := range getModelsForProvider(provider) {
121+ modelItems = append(modelItems, completions.NewCompletionItem(model.Name, model))
122+ }
123+ }
124+ m.modelList.SetItems(modelItems)
125+ return m.modelList.Init()
126+}
127+
128+func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
129+ switch msg := msg.(type) {
130+ case tea.WindowSizeMsg:
131+ m.wWidth = msg.Width
132+ m.wHeight = msg.Height
133+ return m, m.modelList.SetSize(m.listWidth(), m.listHeight())
134+ case tea.KeyPressMsg:
135+ switch {
136+ case key.Matches(msg, m.keyMap.Select):
137+ selectedItemInx := m.modelList.SelectedIndex()
138+ if selectedItemInx == list.NoSelection {
139+ return m, nil // No item selected, do nothing
140+ }
141+ items := m.modelList.Items()
142+ selectedItem := items[selectedItemInx].(completions.CompletionItem).Value().(models.Model)
143+
144+ return m, tea.Sequence(
145+ util.CmdHandler(dialogs.CloseDialogMsg{}),
146+ util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
147+ )
148+ default:
149+ u, cmd := m.modelList.Update(msg)
150+ m.modelList = u.(list.ListModel)
151+ return m, cmd
152+ }
153+ }
154+ return m, nil
155+}
156+
157+func (m *modelDialogCmp) View() tea.View {
158+ t := styles.CurrentTheme()
159+ listView := m.modelList.View()
160+ content := lipgloss.JoinVertical(
161+ lipgloss.Left,
162+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Model", m.width-4)),
163+ listView.String(),
164+ "",
165+ t.S().Base.Width(m.width-2).PaddingLeft(1).AlignHorizontal(lipgloss.Left).Render(m.help.View(m.keyMap)),
166+ )
167+ v := tea.NewView(m.style().Render(content))
168+ if listView.Cursor() != nil {
169+ c := m.moveCursor(listView.Cursor())
170+ v.SetCursor(c)
171+ }
172+ return v
173+}
174+
175+func (m *modelDialogCmp) style() lipgloss.Style {
176+ t := styles.CurrentTheme()
177+ return t.S().Base.
178+ Width(m.width).
179+ Border(lipgloss.RoundedBorder()).
180+ BorderForeground(t.BorderFocus)
181+}
182+
183+func (m *modelDialogCmp) listWidth() int {
184+ return defaultWidth - 2 // 4 for padding
185+}
186+
187+func (m *modelDialogCmp) listHeight() int {
188+ listHeigh := len(m.modelList.Items()) + 2 + 4 // height based on items + 2 for the input + 4 for the sections
189+ return min(listHeigh, m.wHeight/2)
190+}
191+
192+func GetSelectedModel(cfg *config.Config) models.Model {
193+ agentCfg := cfg.Agents[config.AgentCoder]
194+ selectedModelId := agentCfg.Model
195+ return models.SupportedModels[selectedModelId]
196+}
197+
198+func getEnabledProviders(cfg *config.Config) []models.ModelProvider {
199+ var providers []models.ModelProvider
200+ for providerId, provider := range cfg.Providers {
201+ if !provider.Disabled {
202+ providers = append(providers, providerId)
203+ }
204+ }
205+
206+ // Sort by provider popularity
207+ slices.SortFunc(providers, func(a, b models.ModelProvider) int {
208+ rA := ProviderPopularity[a]
209+ rB := ProviderPopularity[b]
210+
211+ // models not included in popularity ranking default to last
212+ if rA == 0 {
213+ rA = 999
214+ }
215+ if rB == 0 {
216+ rB = 999
217+ }
218+ return rA - rB
219+ })
220+ return providers
221+}
222+
223+func getModelsForProvider(provider models.ModelProvider) []models.Model {
224+ var providerModels []models.Model
225+ for _, model := range models.SupportedModels {
226+ if model.Provider == provider {
227+ providerModels = append(providerModels, model)
228+ }
229+ }
230+
231+ // reverse alphabetical order (if llm naming was consistent latest would appear first)
232+ slices.SortFunc(providerModels, func(a, b models.Model) int {
233+ if a.Name > b.Name {
234+ return -1
235+ } else if a.Name < b.Name {
236+ return 1
237+ }
238+ return 0
239+ })
240+
241+ return providerModels
242+}
243+
244+func (m *modelDialogCmp) Position() (int, int) {
245+ row := m.wHeight/4 - 2 // just a bit above the center
246+ col := m.wWidth / 2
247+ col -= m.width / 2
248+ return row, col
249+}
250+
251+func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
252+ row, col := m.Position()
253+ offset := row + 3 // Border + title
254+ cursor.Y += offset
255+ cursor.X = cursor.X + col + 2
256+ return cursor
257+}
258+
259+func (m *modelDialogCmp) ID() dialogs.DialogID {
260+ return ID
261+}
internal/tui/components/dialogs/sessions/sessions.go+7−1
@@ -63,7 +63,13 @@ func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionD
6363 }
6464 }
6565
66− sessionsList := list.New(list.WithFilterable(true), list.WithFilterPlaceholder("Enter a session name"), list.WithKeyMap(listKeyMap), list.WithItems(items))
66+ sessionsList := list.New(
67+ list.WithFilterable(true),
68+ list.WithFilterPlaceholder("Enter a session name"),
69+ list.WithKeyMap(listKeyMap),
70+ list.WithItems(items),
71+ list.WithWrapNavigation(true),
72+ )
6773 help := help.New()
6874 help.Styles = t.S().Help
6975 s := &sessionDialogCmp{
internal/tui/keys.go+7−13
@@ -6,14 +6,13 @@ import (
66)
77
88type KeyMap struct {
9− Logs key.Binding
10− Quit key.Binding
11− Help key.Binding
12− SwitchSession key.Binding
13− Commands key.Binding
14− FilePicker key.Binding
15− Models key.Binding
16− SwitchTheme key.Binding
9+ Logs key.Binding
10+ Quit key.Binding
11+ Help key.Binding
12+ Commands key.Binding
13+ FilePicker key.Binding
14+ Models key.Binding
15+ SwitchTheme key.Binding
1716}
1817
1918func DefaultKeyMap() KeyMap {
@@ -33,11 +32,6 @@ func DefaultKeyMap() KeyMap {
3332 key.WithHelp("ctrl+?", "toggle help"),
3433 ),
3534
36− SwitchSession: key.NewBinding(
37− key.WithKeys("ctrl+s"),
38− key.WithHelp("ctrl+s", "switch session"),
39− ),
40−
4135 Commands: key.NewBinding(
4236 key.WithKeys("ctrl+k"),
4337 key.WithHelp("ctrl+k", "commands"),
internal/tui/tui.go+15−7
@@ -14,6 +14,7 @@ import (
1414 "github.com/opencode-ai/opencode/internal/tui/components/core"
1515 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1616 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
17+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/models"
1718 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
1819 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
1920 "github.com/opencode-ai/opencode/internal/tui/layout"
@@ -116,6 +117,20 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
116117 cmds = append(cmds, pageCmd)
117118 }
118119 return a, tea.Batch(cmds...)
120+ // Commands
121+ case commands.SwitchSessionsMsg:
122+ return a, func() tea.Msg {
123+ allSessions, _ := a.app.Sessions.List(context.Background())
124+ return dialogs.OpenDialogMsg{
125+ Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
126+ }
127+ }
128+ case commands.SwitchModelMsg:
129+ return a, util.CmdHandler(
130+ dialogs.OpenDialogMsg{
131+ Model: models.NewModelDialogCmp(),
132+ },
133+ )
119134 case tea.KeyPressMsg:
120135 return a, a.handleKeyPressMsg(msg)
121136 }
@@ -182,13 +197,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
182197 return util.CmdHandler(dialogs.OpenDialogMsg{
183198 Model: commands.NewCommandDialog(),
184199 })
185− case key.Matches(msg, a.keyMap.SwitchSession):
186− return func() tea.Msg {
187− allSessions, _ := a.app.Sessions.List(context.Background())
188− return dialogs.OpenDialogMsg{
189− Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
190− }
191− }
192200 // Page navigation
193201 case key.Matches(msg, a.keyMap.Logs):
194202 return a.moveToPage(page.LogsPage)
todos.md+2−2
@@ -9,7 +9,7 @@
99
1010## Dialogs
1111
12−- [ ] Move sessions and modal dialog to the commands
12+- [x] Cleanup Commands
1313- [x] Sessions dialog
14−- [ ] Commands
1514- [ ] Models
15+- [~] Move sessions and model dialog to the commands