diff --git a/packages/tui/internal/components/dialog/search.go b/packages/tui/internal/components/dialog/search.go index cdb2b824ea4a..cad09857c6f9 100644 --- a/packages/tui/internal/components/dialog/search.go +++ b/packages/tui/internal/components/dialog/search.go @@ -30,6 +30,12 @@ type SearchRemoveItemMsg struct { Index int } +// SearchSelectionChangedMsg is emitted when the selection changes +type SearchSelectionChangedMsg struct { + Item any + Index int +} + // SearchDialog is a reusable component that combines a text input with a list type SearchDialog struct { textInput textinput.Model @@ -167,20 +173,34 @@ func (s *SearchDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case key.Matches(msg, searchKeys.Up): + _, prevIdx := s.list.GetSelectedItem() var cmd tea.Cmd listModel, cmd := s.list.Update(msg) s.list = listModel.(list.List[list.Item]) if cmd != nil { cmds = append(cmds, cmd) } + // Check if selection changed + if newItem, newIdx := s.list.GetSelectedItem(); newIdx != prevIdx { + cmds = append(cmds, func() tea.Msg { + return SearchSelectionChangedMsg{Item: newItem, Index: newIdx} + }) + } case key.Matches(msg, searchKeys.Down): + _, prevIdx := s.list.GetSelectedItem() var cmd tea.Cmd listModel, cmd := s.list.Update(msg) s.list = listModel.(list.List[list.Item]) if cmd != nil { cmds = append(cmds, cmd) } + // Check if selection changed + if newItem, newIdx := s.list.GetSelectedItem(); newIdx != prevIdx { + cmds = append(cmds, func() tea.Msg { + return SearchSelectionChangedMsg{Item: newItem, Index: newIdx} + }) + } default: oldValue := s.textInput.Value() @@ -245,3 +265,13 @@ func (s *SearchDialog) Blur() { s.focused = false s.textInput.Blur() } + +// SetSelectedIndex sets the selected index in the list +func (s *SearchDialog) SetSelectedIndex(index int) { + s.list.SetSelectedIndex(index) +} + +// GetSelectedItem returns the currently selected item and its index +func (s *SearchDialog) GetSelectedItem() (list.Item, int) { + return s.list.GetSelectedItem() +} diff --git a/packages/tui/internal/components/dialog/theme.go b/packages/tui/internal/components/dialog/theme.go index c71cddc8e2ed..1e6ced2f067e 100644 --- a/packages/tui/internal/components/dialog/theme.go +++ b/packages/tui/internal/components/dialog/theme.go @@ -1,8 +1,12 @@ package dialog import ( + "sort" + tea "github.com/charmbracelet/bubbletea/v2" - list "github.com/sst/opencode/internal/components/list" + "github.com/lithammer/fuzzysearch/fuzzy" + "github.com/sst/opencode/internal/app" + "github.com/sst/opencode/internal/components/list" "github.com/sst/opencode/internal/components/modal" "github.com/sst/opencode/internal/layout" "github.com/sst/opencode/internal/styles" @@ -10,73 +14,94 @@ import ( "github.com/sst/opencode/internal/util" ) -// ThemeSelectedMsg is sent when the theme is changed -type ThemeSelectedMsg struct { - ThemeName string -} +const dialogWidth = 50 + +// Theme messages +type ( + ThemeSelectedMsg struct{ ThemeName string } + ThemePreviewMsg struct{ ThemeName string } +) -// ThemeDialog interface for the theme switching dialog type ThemeDialog interface { layout.Modal } type themeDialog struct { - width int - height int - + app *app.App modal *modal.Modal - list list.List[list.Item] + searchDialog *SearchDialog originalTheme string themeApplied bool } +type themeItem struct { + name string +} + +func (t themeItem) Render(selected bool, width int, baseStyle styles.Style) string { + currentTheme := theme.CurrentTheme() + style := baseStyle.Background(currentTheme.BackgroundPanel()).Foreground(currentTheme.Text()) + if selected { + style = style.Foreground(currentTheme.Primary()) + } + return style.PaddingLeft(1).Render(t.name) +} + +func (t themeItem) Selectable() bool { return true } + func (t *themeDialog) Init() tea.Cmd { - return nil + t.setupDialog() + return t.searchDialog.Init() } func (t *themeDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { - case tea.WindowSizeMsg: - t.width = msg.Width - t.height = msg.Height - case tea.KeyMsg: - switch msg.String() { - case "enter": - if item, idx := t.list.GetSelectedItem(); idx >= 0 { - if stringItem, ok := item.(list.StringItem); ok { - selectedTheme := string(stringItem) - if err := theme.SetTheme(selectedTheme); err != nil { - // status.Error(err.Error()) - return t, nil - } - t.themeApplied = true - return t, tea.Sequence( - util.CmdHandler(modal.CloseModalMsg{}), - util.CmdHandler(ThemeSelectedMsg{ThemeName: selectedTheme}), - ) - } - } - + case SearchSelectionMsg: + if item, ok := msg.Item.(themeItem); ok { + theme.SetTheme(item.name) + t.themeApplied = true + return t, tea.Sequence( + util.CmdHandler(modal.CloseModalMsg{}), + util.CmdHandler(ThemeSelectedMsg{ThemeName: item.name}), + ) } - } + return t, util.CmdHandler(modal.CloseModalMsg{}) + + case SearchCancelledMsg: + return t, util.CmdHandler(modal.CloseModalMsg{}) - _, prevIdx := t.list.GetSelectedItem() + case SearchRemoveItemMsg: + // No recent themes functionality + return t, nil - var cmd tea.Cmd - listModel, cmd := t.list.Update(msg) - t.list = listModel.(list.List[list.Item]) + case SearchQueryChangedMsg: + t.refreshItems() + t.previewFirstTheme() + return t, nil - if item, newIdx := t.list.GetSelectedItem(); newIdx >= 0 && newIdx != prevIdx { - if stringItem, ok := item.(list.StringItem); ok { - theme.SetTheme(string(stringItem)) - return t, util.CmdHandler(ThemeSelectedMsg{ThemeName: string(stringItem)}) + case SearchSelectionChangedMsg: + if item, ok := msg.Item.(themeItem); ok { + theme.SetTheme(item.name) + return t, util.CmdHandler(ThemePreviewMsg{ThemeName: item.name}) } + return t, nil + + case tea.WindowSizeMsg: + t.searchDialog.SetWidth(dialogWidth) + t.searchDialog.SetHeight(msg.Height) } + + updatedDialog, cmd := t.searchDialog.Update(msg) + t.searchDialog = updatedDialog.(*SearchDialog) return t, cmd } +func (t *themeDialog) View() string { + return t.searchDialog.View() +} + func (t *themeDialog) Render(background string) string { - return t.modal.Render(t.list.View(), background) + return t.modal.Render(t.View(), background) } func (t *themeDialog) Close() tea.Cmd { @@ -87,46 +112,86 @@ func (t *themeDialog) Close() tea.Cmd { return nil } -// NewThemeDialog creates a new theme switching dialog -func NewThemeDialog() ThemeDialog { - themes := theme.AvailableThemes() - currentTheme := theme.CurrentThemeName() +func (t *themeDialog) setupDialog() { + t.searchDialog = NewSearchDialog("Search themes...", 10) + t.searchDialog.SetWidth(dialogWidth) + t.searchDialog.Focus() + t.refreshItems() + t.setCurrentThemeSelection() +} + +func (t *themeDialog) refreshItems() { + query := t.searchDialog.GetQuery() + items := t.buildItems(query) + t.searchDialog.SetItems(items) +} + +func (t *themeDialog) buildItems(query string) []list.Item { + allThemes := theme.AvailableThemes() + + if query != "" { + return t.buildSearchItems(query, allThemes) + } + return t.buildAllThemes(allThemes) +} + +func (t *themeDialog) buildSearchItems(query string, themes []string) []list.Item { + matches := fuzzy.RankFindFold(query, themes) + sort.Sort(matches) + + items := make([]list.Item, len(matches)) + for i, match := range matches { + items[i] = themeItem{name: match.Target} + } + return items +} + +func (t *themeDialog) buildAllThemes(themes []string) []list.Item { + var items []list.Item - var selectedIdx int - for i, name := range themes { - if name == currentTheme { - selectedIdx = i + // Sort themes alphabetically + sorted := make([]string, len(themes)) + copy(sorted, themes) + sort.Strings(sorted) + + for _, name := range sorted { + items = append(items, themeItem{name: name}) + } + + return items +} + +func (t *themeDialog) setCurrentThemeSelection() { + current := theme.CurrentThemeName() + items := t.buildItems("") + + for i, item := range items { + if themeItem, ok := item.(themeItem); ok && themeItem.name == current { + t.searchDialog.SetSelectedIndex(i) + break } } +} - // Convert themes to list items - items := make([]list.Item, len(themes)) - for i, theme := range themes { - items[i] = list.StringItem(theme) +func (t *themeDialog) previewFirstTheme() { + items := t.buildItems(t.searchDialog.GetQuery()) + for _, item := range items { + if themeItem, ok := item.(themeItem); ok { + theme.SetTheme(themeItem.name) + break + } } +} - listComponent := list.NewListComponent( - list.WithItems(items), - list.WithMaxVisibleHeight[list.Item](10), - list.WithFallbackMessage[list.Item]("No themes available"), - list.WithAlphaNumericKeys[list.Item](true), - list.WithRenderFunc(func(item list.Item, selected bool, width int, baseStyle styles.Style) string { - return item.Render(selected, width, baseStyle) - }), - list.WithSelectableFunc(func(item list.Item) bool { - return item.Selectable() - }), - ) - - // Set the initial selection to the current theme - listComponent.SetSelectedIndex(selectedIdx) - - // Set the max width for the list to match the modal width - listComponent.SetMaxWidth(36) // 40 (modal max width) - 4 (modal padding) - return &themeDialog{ - list: listComponent, - modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(40)), - originalTheme: currentTheme, - themeApplied: false, +// NewThemeDialog creates a new theme selection dialog +func NewThemeDialog(app *app.App) ThemeDialog { + dialog := &themeDialog{ + app: app, + originalTheme: theme.CurrentThemeName(), + modal: modal.New(modal.WithTitle("Select Theme"), modal.WithMaxWidth(dialogWidth+4)), } + + dialog.setupDialog() + + return dialog } diff --git a/packages/tui/internal/config/config.go b/packages/tui/internal/config/config.go index d20376dd82a3..55a07c429419 100644 --- a/packages/tui/internal/config/config.go +++ b/packages/tui/internal/config/config.go @@ -16,6 +16,11 @@ type ModelUsage struct { LastUsed time.Time `toml:"last_used"` } +type ThemeUsage struct { + ThemeName string `toml:"theme_name"` + LastUsed time.Time `toml:"last_used"` +} + type ModeModel struct { ProviderID string `toml:"provider_id"` ModelID string `toml:"model_id"` @@ -28,6 +33,7 @@ type State struct { Model string `toml:"model"` Mode string `toml:"mode"` RecentlyUsedModels []ModelUsage `toml:"recently_used_models"` + RecentlyUsedThemes []ThemeUsage `toml:"recently_used_themes"` MessagesRight bool `toml:"messages_right"` SplitDiff bool `toml:"split_diff"` } @@ -38,6 +44,7 @@ func NewState() *State { Mode: "build", ModeModel: make(map[string]ModeModel), RecentlyUsedModels: make([]ModelUsage, 0), + RecentlyUsedThemes: make([]ThemeUsage, 0), } } @@ -78,6 +85,40 @@ func (s *State) RemoveModelFromRecentlyUsed(providerID, modelID string) { } } +// UpdateThemeUsage updates the recently used themes list +func (s *State) UpdateThemeUsage(themeName string) { + now := time.Now() + + // Move existing theme to front + for i, usage := range s.RecentlyUsedThemes { + if usage.ThemeName == themeName { + s.RecentlyUsedThemes[i].LastUsed = now + usage := s.RecentlyUsedThemes[i] + copy(s.RecentlyUsedThemes[1:i+1], s.RecentlyUsedThemes[0:i]) + s.RecentlyUsedThemes[0] = usage + return + } + } + + // Add new theme at front + newUsage := ThemeUsage{ThemeName: themeName, LastUsed: now} + s.RecentlyUsedThemes = append([]ThemeUsage{newUsage}, s.RecentlyUsedThemes...) + + // Keep only the most recent 20 themes + if len(s.RecentlyUsedThemes) > 20 { + s.RecentlyUsedThemes = s.RecentlyUsedThemes[:20] + } +} + +func (s *State) RemoveThemeFromRecentlyUsed(themeName string) { + for i, usage := range s.RecentlyUsedThemes { + if usage.ThemeName == themeName { + s.RecentlyUsedThemes = append(s.RecentlyUsedThemes[:i], s.RecentlyUsedThemes[i+1:]...) + return + } + } +} + // SaveState writes the provided Config struct to the specified TOML file. // It will create the file if it doesn't exist, or overwrite it if it does. func SaveState(filePath string, state *State) error { diff --git a/packages/tui/internal/tui/tui.go b/packages/tui/internal/tui/tui.go index 6210ab7b130f..f1a073da7c57 100644 --- a/packages/tui/internal/tui/tui.go +++ b/packages/tui/internal/tui/tui.go @@ -54,8 +54,10 @@ const ( ExitKeyFirstPress ) -const interruptDebounceTimeout = 1 * time.Second -const exitDebounceTimeout = 1 * time.Second +const ( + interruptDebounceTimeout = 1 * time.Second + exitDebounceTimeout = 1 * time.Second +) type appModel struct { width, height int @@ -473,6 +475,9 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case dialog.ThemeSelectedMsg: a.app.State.Theme = msg.ThemeName a.app.SaveState() + case dialog.ThemePreviewMsg: + // Handle theme preview without saving state + a.app.State.Theme = msg.ThemeName case toast.ShowToastMsg: tm, cmd := a.toastManager.Update(msg) a.toastManager = tm @@ -905,7 +910,7 @@ func (a appModel) executeCommand(command commands.Command) (tea.Model, tea.Cmd) modelDialog := dialog.NewModelDialog(a.app) a.modal = modelDialog case commands.ThemeListCommand: - themeDialog := dialog.NewThemeDialog() + themeDialog := dialog.NewThemeDialog(a.app) a.modal = themeDialog // case commands.FileListCommand: // a.editor.Blur()