diff --git a/internal/tui/components/completions/completions.go b/internal/tui/components/completions/completions.go index 7733aac48ccc27c4b43a61880873009d77ff0a66..9ad622a9e5fca17665f93d0d1c7c6bcef4575329 100644 --- a/internal/tui/components/completions/completions.go +++ b/internal/tui/components/completions/completions.go @@ -133,8 +133,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { c.x = msg.X c.y = msg.Y items := []util.Model{} + t := styles.CurrentTheme() for _, completion := range msg.Completions { - item := NewCompletionItem(completion.Title, completion.Value) + item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle)) items = append(items, item) } c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go index 20782645888d232a5253e2070f4e7773978b9ddc..f7a2f628115fb4dee6957cf6b6968b7375b40e7f 100644 --- a/internal/tui/components/completions/item.go +++ b/internal/tui/components/completions/item.go @@ -1,13 +1,14 @@ package completions import ( + "image/color" + 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/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" "github.com/rivo/uniseg" ) @@ -27,16 +28,35 @@ type completionItemCmp struct { value any focus bool matchIndexes []int + bgColor color.Color +} + +type completionOptions func(*completionItemCmp) + +func WithBackgroundColor(c color.Color) completionOptions { + return func(cmp *completionItemCmp) { + cmp.bgColor = c + } } -func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem { - return &completionItemCmp{ - text: text, - value: value, - matchIndexes: matchIndexes, +func WithMatchIndexes(indexes ...int) completionOptions { + return func(cmp *completionItemCmp) { + cmp.matchIndexes = indexes } } +func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem { + c := &completionItemCmp{ + text: text, + value: value, + } + + for _, opt := range opts { + opt(c) + } + return c +} + // Init implements CommandItem. func (c *completionItemCmp) Init() tea.Cmd { return nil @@ -49,15 +69,18 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { // View implements CommandItem. func (c *completionItemCmp) View() tea.View { - t := theme.CurrentTheme() + t := styles.CurrentTheme() - baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary()) - titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text()) - titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true) + titleStyle := t.S().Text.Padding(0, 1).Width(c.width) + titleMatchStyle := t.S().Text.Underline(true) + if c.bgColor != nil { + titleStyle = titleStyle.Background(c.bgColor) + titleMatchStyle = titleMatchStyle.Background(c.bgColor) + } if c.focus { - titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) - titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true) + titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width) + titleMatchStyle = t.S().TextSelected.Underline(true) } var truncatedTitle string diff --git a/internal/tui/components/core/helpers.go b/internal/tui/components/core/helpers.go index efc3ac745691f11c0a5769ec7761c5f150f18217..60c9709bf0ae0560e40b5e1994e89ab2f055d22e 100644 --- a/internal/tui/components/core/helpers.go +++ b/internal/tui/components/core/helpers.go @@ -9,13 +9,26 @@ import ( "github.com/opencode-ai/opencode/internal/tui/styles" ) -func Section(title string, width int) string { +func Section(text string, width int) string { t := styles.CurrentTheme() char := "─" + length := len(text) + 1 + remainingWidth := width - length + if remainingWidth > 0 { + text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth)) + } + return text +} + +func Title(title string, width int) string { + t := styles.CurrentTheme() + char := "╱" length := len(title) + 1 remainingWidth := width - length + lineStyle := t.S().Base.Foreground(t.Primary) + titleStyle := t.S().Base.Foreground(t.Secondary) if remainingWidth > 0 { - title = title + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth)) + title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth)) } return title } diff --git a/internal/tui/components/core/list/list.go b/internal/tui/components/core/list/list.go index 3a7290967a96382fc86e2fc7d1e9aeba6fede8c8..996bd3c11e716f0b79f503736783ba3cb431de2f 100644 --- a/internal/tui/components/core/list/list.go +++ b/internal/tui/components/core/list/list.go @@ -39,6 +39,7 @@ type ListModel interface { ResetView() // Clear rendering cache and reset scroll position Items() []util.Model // Get all items in the list SelectedIndex() int // Get the index of the currently selected item + SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it Filter(string) tea.Cmd // Filter items based on a search term } @@ -133,11 +134,12 @@ type model struct { gapSize int // Number of empty lines between items padding []int // Padding around the list content - filterable bool // Whether items can be filtered - filteredItems []util.Model // Filtered items based on current search - input textinput.Model // Input field for filtering items - hideFilterInput bool // Whether to hide the filter input field - currentSearch string // Current search term for filtering + 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 + hideFilterInput bool // Whether to hide the filter input field + currentSearch string // Current search term for filtering } // listOptions is a function type for configuring list options. @@ -195,29 +197,39 @@ func WithHideFilterInput(hide bool) listOptions { } } +// WithFilterPlaceholder sets the placeholder text for the filter input field. +func WithFilterPlaceholder(placeholder string) listOptions { + return func(m *model) { + m.filterPlaceholder = placeholder + } +} + // 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 { m := &model{ - help: help.New(), - keyMap: DefaultKeyMap(), - allItems: []util.Model{}, - filteredItems: []util.Model{}, - renderState: newRenderState(), - gapSize: DefaultGapSize, - padding: []int{}, - selectionState: selectionState{selectedIndex: NoSelection}, + help: help.New(), + keyMap: DefaultKeyMap(), + allItems: []util.Model{}, + filteredItems: []util.Model{}, + renderState: newRenderState(), + gapSize: DefaultGapSize, + padding: []int{}, + selectionState: selectionState{selectedIndex: NoSelection}, + filterPlaceholder: "Type to filter...", } for _, opt := range opts { opt(m) } if m.filterable && !m.hideFilterInput { + t := styles.CurrentTheme() ti := textinput.New() - ti.Placeholder = "Type to filter..." + ti.Placeholder = m.filterPlaceholder ti.SetVirtualCursor(false) ti.Focus() + ti.SetStyles(t.S().TextInput) m.input = ti // disable j,k movements @@ -616,7 +628,7 @@ func (m *model) isSectionHeader(index int) bool { // findFirstSelectableItem finds the first item that is not a section header. func (m *model) findFirstSelectableItem() int { - for i := 0; i < len(m.filteredItems); i++ { + for i := range m.filteredItems { if !m.isSectionHeader(i) { return i } @@ -944,7 +956,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd { m.viewState.width = width m.ResetView() if m.filterable && !m.hideFilterInput { - m.input.SetWidth(m.getItemWidth() - 3) + m.input.SetWidth(m.getItemWidth() - 5) } return m.setAllItemsSize() } @@ -1096,7 +1108,7 @@ func (m *model) SetItems(items []util.Model) tea.Cmd { } func (c *model) inputStyle() lipgloss.Style { - return styles.BaseStyle() + return styles.BaseStyle().Padding(0, 1, 1, 1) } // section represents a group of items under a section header. @@ -1275,3 +1287,22 @@ func (m *model) SelectedIndex() int { } return m.selectionState.selectedIndex } + +// SetSelected sets the selected item by index and automatically scrolls to make it visible. +// If the index is invalid or points to a section header, it finds the nearest selectable item. +func (m *model) SetSelected(index int) tea.Cmd { + changeNeeded := m.selectionState.selectedIndex - index + cmds := []tea.Cmd{} + if changeNeeded < 0 { + for range -changeNeeded { + cmds = append(cmds, m.selectNextItem()) + m.renderVisible() + } + } else if changeNeeded > 0 { + for range changeNeeded { + cmds = append(cmds, m.selectPreviousItem()) + m.renderVisible() + } + } + return tea.Batch(cmds...) +} diff --git a/internal/tui/components/dialogs/commands/arguments.go b/internal/tui/components/dialogs/commands/arguments.go index 16963c22dc756483e18e616f1fa44dd425accd7d..730c50c490bbd4fec98653b82e81589f6e2bfeda 100644 --- a/internal/tui/components/dialogs/commands/arguments.go +++ b/internal/tui/components/dialogs/commands/arguments.go @@ -220,9 +220,9 @@ func (c *commandArgumentsDialogCmp) View() tea.View { } func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { - offset := 13 + (1+c.focusIndex)*3 + row, col := c.Position() + offset := row + 3 + (1+c.focusIndex)*3 cursor.Y += offset - _, col := c.Position() cursor.X = cursor.X + col + 3 return cursor } @@ -237,10 +237,11 @@ func (c *commandArgumentsDialogCmp) style() lipgloss.Style { BorderForeground(t.TextMuted()) } -func (q *commandArgumentsDialogCmp) Position() (int, int) { - row := 10 - col := q.wWidth / 2 - col -= q.width / 2 +func (c *commandArgumentsDialogCmp) Position() (int, int) { + row := c.wHeight / 2 + row -= c.wHeight / 2 + col := c.wWidth / 2 + col -= c.width / 2 return row, col } diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 92c2695f5aff71e640aeb41f165237766644210d..4960f086a64f5356f4b5c1643d1b72076b786df2 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -14,16 +14,16 @@ type CommandsDialogKeyMap struct { func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { return CommandsDialogKeyMap{ Select: key.NewBinding( - key.WithKeys("enter"), + key.WithKeys("enter", "tab", "ctrl+y"), key.WithHelp("enter", "confirm"), ), Next: key.NewBinding( - key.WithKeys("tab", "down"), - key.WithHelp("tab/↓", "next"), + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), ), Previous: key.NewBinding( - key.WithKeys("shift+tab", "up"), - key.WithHelp("shift+tab/↑", "previous"), + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), ), } } diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go new file mode 100644 index 0000000000000000000000000000000000000000..2ec423d865cbcaa330f87dc652b60556c4886f33 --- /dev/null +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -0,0 +1,56 @@ +package sessions + +import ( + "github.com/charmbracelet/bubbles/v2/key" + "github.com/opencode-ai/opencode/internal/tui/layout" +) + +type KeyMap struct { + Select key.Binding + Next key.Binding + Previous key.Binding +} + +func DefaultKeyMap() KeyMap { + return KeyMap{ + Select: key.NewBinding( + key.WithKeys("enter", "tab", "ctrl+y"), + key.WithHelp("enter", "confirm"), + ), + Next: key.NewBinding( + key.WithKeys("down", "ctrl+n"), + key.WithHelp("↓", "next item"), + ), + Previous: key.NewBinding( + key.WithKeys("up", "ctrl+p"), + key.WithHelp("↑", "previous item"), + ), + } +} + +// FullHelp implements help.KeyMap. +func (k KeyMap) FullHelp() [][]key.Binding { + m := [][]key.Binding{} + slice := layout.KeyMapToSlice(k) + for i := 0; i < len(slice); i += 4 { + end := min(i+4, len(slice)) + m = append(m, slice[i:end]) + } + return m +} + +// ShortHelp implements help.KeyMap. +func (k KeyMap) ShortHelp() []key.Binding { + return []key.Binding{ + key.NewBinding( + + key.WithKeys("down", "up"), + key.WithHelp("↑↓", "choose"), + ), + k.Select, + key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), + } +} diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go new file mode 100644 index 0000000000000000000000000000000000000000..0339877dd44b3577ca3e7073e1353ebcb41a6612 --- /dev/null +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -0,0 +1,172 @@ +package sessions + +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/session" + "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/util" +) + +const id dialogs.DialogID = "sessions" + +// SessionDialog interface for the session switching dialog +type SessionDialog interface { + dialogs.DialogModel +} + +type sessionDialogCmp struct { + selectedInx int + wWidth int + wHeight int + width int + selectedSessionID string + keyMap KeyMap + sessionsList list.ListModel + renderedSelected bool + help help.Model +} + +// NewSessionDialogCmp creates a new session switching dialog +func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog { + t := styles.CurrentTheme() + 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 + + selectedInx := 0 + items := make([]util.Model, len(sessions)) + if len(sessions) > 0 { + for i, session := range sessions { + items[i] = completions.NewCompletionItem(session.Title, session) + if session.ID == selectedID { + selectedInx = i + } + } + } + + sessionsList := list.New(list.WithFilterable(true), list.WithFilterPlaceholder("Enter a session name"), list.WithKeyMap(listKeyMap), list.WithItems(items)) + help := help.New() + help.Styles = t.S().Help + s := &sessionDialogCmp{ + selectedInx: selectedInx, + selectedSessionID: selectedID, + keyMap: DefaultKeyMap(), + sessionsList: sessionsList, + help: help, + } + + return s +} + +func (s *sessionDialogCmp) Init() tea.Cmd { + return s.sessionsList.Init() +} + +func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + s.wWidth = msg.Width + s.wHeight = msg.Height + s.width = s.wWidth / 2 + var cmds []tea.Cmd + cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight())) + if !s.renderedSelected { + cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx)) + s.renderedSelected = true + } + return s, tea.Sequence(cmds...) + case tea.KeyPressMsg: + switch { + case key.Matches(msg, s.keyMap.Select): + if len(s.sessionsList.Items()) > 0 { + items := s.sessionsList.Items() + selectedItemInx := s.sessionsList.SelectedIndex() + return s, tea.Sequence( + util.CmdHandler(dialogs.CloseDialogMsg{}), + util.CmdHandler( + chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)), + ), + ) + } + default: + u, cmd := s.sessionsList.Update(msg) + s.sessionsList = u.(list.ListModel) + return s, cmd + } + } + return s, nil +} + +func (s *sessionDialogCmp) View() tea.View { + t := styles.CurrentTheme() + listView := s.sessionsList.View() + content := lipgloss.JoinVertical( + lipgloss.Left, + t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)), + listView.String(), + "", + t.S().Base.Width(s.width-2).PaddingRight(2).AlignHorizontal(lipgloss.Right).Render(s.help.View(s.keyMap)), + ) + + v := tea.NewView(s.style().Render(content)) + if listView.Cursor() != nil { + c := s.moveCursor(listView.Cursor()) + v.SetCursor(c) + } + return v +} + +func (s *sessionDialogCmp) style() lipgloss.Style { + t := styles.CurrentTheme() + return t.S().Base. + Width(s.width). + Border(lipgloss.RoundedBorder()). + BorderForeground(t.BorderFocus) +} + +func (s *sessionDialogCmp) listHeight() int { + return s.wHeight/2 - 6 // 5 for the border, title and help +} + +func (s *sessionDialogCmp) listWidth() int { + return s.width - 2 // 2 for the border +} + +func (s *sessionDialogCmp) Position() (int, int) { + row := s.wHeight/4 - 2 // just a bit above the center + col := s.wWidth / 2 + col -= s.width / 2 + return row, col +} + +func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { + row, col := s.Position() + offset := row + 3 // Border + title + cursor.Y += offset + cursor.X = cursor.X + col + 2 + return cursor +} + +// ID implements SessionDialog. +func (s *sessionDialogCmp) ID() dialogs.DialogID { + return id +} diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go index 4d01ccc0834f944ebb12f8641ee6f1f2da0ec58d..2213c7288a94a43ba5d2d3769752243ad081c734 100644 --- a/internal/tui/layout/layout.go +++ b/internal/tui/layout/layout.go @@ -3,6 +3,7 @@ package layout import ( "reflect" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" ) @@ -26,6 +27,10 @@ type Positionable interface { SetPosition(x, y int) tea.Cmd } +type Help interface { + Help() help.KeyMap +} + func KeyMapToSlice(t any) (bindings []key.Binding) { typ := reflect.TypeOf(t) if typ.Kind() != reflect.Struct { diff --git a/internal/tui/styles/crush.go b/internal/tui/styles/crush.go index 348e2b5c961895590130a02017d4aeab8dd98a94..5f2fbd94b7547068bf324024612039e30e8af29e 100644 --- a/internal/tui/styles/crush.go +++ b/internal/tui/styles/crush.go @@ -10,30 +10,33 @@ func NewCrushTheme() *Theme { Name: "crush", IsDark: true, - Primary: charmtone.Charple, - Secondary: charmtone.Dolly, - Tertiary: charmtone.Bok, - Accent: charmtone.Zest, + Primary: lipgloss.Color(charmtone.Charple.Hex()), + Secondary: lipgloss.Color(charmtone.Dolly.Hex()), + Tertiary: lipgloss.Color(charmtone.Bok.Hex()), + Accent: lipgloss.Color(charmtone.Zest.Hex()), + + PrimaryLight: lipgloss.Color(charmtone.Hazy.Hex()), // Backgrounds - BgBase: charmtone.Pepper, - BgSubtle: charmtone.Charcoal, - BgOverlay: charmtone.Iron, + BgBase: lipgloss.Color(charmtone.Pepper.Hex()), + BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()), + BgOverlay: lipgloss.Color(charmtone.Iron.Hex()), // Foregrounds - FgBase: charmtone.Ash, - FgMuted: charmtone.Squid, - FgSubtle: charmtone.Oyster, + FgBase: lipgloss.Color(charmtone.Ash.Hex()), + FgMuted: lipgloss.Color(charmtone.Squid.Hex()), + FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()), + FgSelected: lipgloss.Color(charmtone.Salt.Hex()), // Borders - Border: charmtone.Charcoal, - BorderFocus: charmtone.Charple, + Border: lipgloss.Color(charmtone.Charcoal.Hex()), + BorderFocus: lipgloss.Color(charmtone.Charple.Hex()), // Status - Success: charmtone.Guac, - Error: charmtone.Sriracha, - Warning: charmtone.Uni, - Info: charmtone.Malibu, + Success: lipgloss.Color(charmtone.Guac.Hex()), + Error: lipgloss.Color(charmtone.Sriracha.Hex()), + Warning: lipgloss.Color(charmtone.Uni.Hex()), + Info: lipgloss.Color(charmtone.Malibu.Hex()), // TODO: fix this. SyntaxBg: lipgloss.Color("#1C1C1F"), diff --git a/internal/tui/styles/theme.go b/internal/tui/styles/theme.go index c14bab9c6bda3772749dcd2f13931d630fac2b46..7533b7351773f21868341b66c0adf85104a54ff6 100644 --- a/internal/tui/styles/theme.go +++ b/internal/tui/styles/theme.go @@ -4,7 +4,9 @@ import ( "fmt" "image/color" + "github.com/charmbracelet/bubbles/v2/help" "github.com/charmbracelet/bubbles/v2/textarea" + "github.com/charmbracelet/bubbles/v2/textinput" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/glamour/v2/ansi" "github.com/charmbracelet/lipgloss/v2" @@ -25,13 +27,16 @@ type Theme struct { Tertiary color.Color Accent color.Color + PrimaryLight color.Color + BgBase color.Color BgSubtle color.Color BgOverlay color.Color - FgBase color.Color - FgMuted color.Color - FgSubtle color.Color + FgBase color.Color + FgMuted color.Color + FgSubtle color.Color + FgSelected color.Color Border color.Color BorderFocus color.Color @@ -51,24 +56,30 @@ type Theme struct { } type Styles struct { - Base lipgloss.Style + Base lipgloss.Style + SelectedBase lipgloss.Style - Title lipgloss.Style - Subtitle lipgloss.Style - Text lipgloss.Style - Muted lipgloss.Style - Subtle lipgloss.Style + Title lipgloss.Style + Subtitle lipgloss.Style + Text lipgloss.Style + TextSelected lipgloss.Style + Muted lipgloss.Style + Subtle lipgloss.Style Success lipgloss.Style Error lipgloss.Style Warning lipgloss.Style Info lipgloss.Style - // Markdown & Chroma + // Markdown & Chroma Markdown ansi.StyleConfig // Inputs - TextArea textarea.Styles + TextInput textinput.Styles + TextArea textarea.Styles + + // Help + Help help.Styles } func (t *Theme) S() *Styles { @@ -84,6 +95,8 @@ func (t *Theme) buildStyles() *Styles { return &Styles{ Base: base, + SelectedBase: base.Background(t.Primary), + Title: base. Foreground(t.Accent). Bold(true), @@ -92,7 +105,8 @@ func (t *Theme) buildStyles() *Styles { Foreground(t.Secondary). Bold(true), - Text: base, + Text: base, + TextSelected: base.Background(t.Primary).Foreground(t.FgSelected), Muted: base.Foreground(t.FgMuted), @@ -106,6 +120,25 @@ func (t *Theme) buildStyles() *Styles { Info: base.Foreground(t.Info), + TextInput: textinput.Styles{ + Focused: textinput.StyleState{ + Text: base, + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.Tertiary), + Suggestion: base.Foreground(t.FgMuted), + }, + Blurred: textinput.StyleState{ + Text: base.Foreground(t.FgMuted), + Placeholder: base.Foreground(t.FgMuted), + Prompt: base.Foreground(t.FgMuted), + Suggestion: base.Foreground(t.FgMuted), + }, + Cursor: textinput.CursorStyle{ + Color: t.Secondary, + Shape: tea.CursorBar, + Blink: true, + }, + }, TextArea: textarea.Styles{ Focused: textarea.StyleState{ Base: base, @@ -341,6 +374,16 @@ func (t *Theme) buildStyles() *Styles { BlockPrefix: "\n ", }, }, + + Help: help.Styles{ + ShortKey: base.Foreground(t.FgMuted), + ShortDesc: base.Foreground(t.FgSubtle), + ShortSeparator: base.Foreground(t.Border), + Ellipsis: base.Foreground(t.Border), + FullKey: base.Foreground(t.FgMuted), + FullDesc: base.Foreground(t.FgSubtle), + FullSeparator: base.Foreground(t.Border), + }, } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 2cb0af4c681f232daea4f36978db5f4c9e18f885..47b17014b56cd18ea61597b52d26859a8eccaf11 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1,17 +1,21 @@ package tui import ( + "context" + "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/app" "github.com/opencode-ai/opencode/internal/logging" "github.com/opencode-ai/opencode/internal/pubsub" + cmpChat "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/dialogs" "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands" "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" "github.com/opencode-ai/opencode/internal/tui/page" "github.com/opencode-ai/opencode/internal/tui/page/chat" @@ -35,6 +39,9 @@ type appModel struct { dialog dialogs.DialogCmp completions completions.Completions + + // Session + selectedSessionID string // The ID of the currently selected session } // Init initializes the application model and returns initial commands. @@ -90,6 +97,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, statusCmd) return a, tea.Batch(cmds...) + // Session + case cmpChat.SessionSelectedMsg: + a.selectedSessionID = msg.ID // Logs case pubsub.Event[logging.LogMessage]: // Send to the status component @@ -170,7 +180,13 @@ 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)