sessions dialog

Kujtim Hoxha created

Change summary

internal/tui/components/completions/completions.go    |   3 
internal/tui/components/completions/item.go           |  47 ++
internal/tui/components/core/helpers.go               |  17 +
internal/tui/components/core/list/list.go             |  65 +++-
internal/tui/components/dialogs/commands/arguments.go |  13 
internal/tui/components/dialogs/commands/keys.go      |  10 
internal/tui/components/dialogs/sessions/keys.go      |  56 ++++
internal/tui/components/dialogs/sessions/sessions.go  | 172 +++++++++++++
internal/tui/layout/layout.go                         |   5 
internal/tui/styles/crush.go                          |  35 +-
internal/tui/styles/theme.go                          |  67 ++++
internal/tui/tui.go                                   |  18 +
12 files changed, 436 insertions(+), 72 deletions(-)

Detailed changes

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

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

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
 }

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

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
 }
 

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

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

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

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 {

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

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

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)