Detailed changes
@@ -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
@@ -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
@@ -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
}
@@ -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...)
+}
@@ -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
}
@@ -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"),
),
}
}
@@ -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"),
+ ),
+ }
+}
@@ -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
+}
@@ -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 {
@@ -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"),
@@ -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),
+ },
}
}
@@ -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)