on main help sign in
sessions dialog
commit1b178f58ad17e3fd91c54787fd13fbbe56ccdea0 authorKujtim Hoxha <kujtimii.h@gmail.com> dateSun 1 Jun 2025, 22:10 (1 year ago) parentdff1bac
12 files changed+436−72
internal/tui/components/completions/completions.go+2−1 internal/tui/components/completions/item.go+35−12 internal/tui/components/core/helpers.go+15−2 internal/tui/components/core/list/list.go+48−17 internal/tui/components/dialogs/commands/arguments.go+7−6 internal/tui/components/dialogs/commands/keys.go+5−5 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+19−16 internal/tui/styles/theme.go+55−12 internal/tui/tui.go+17−1
internal/tui/components/completions/completions.go+2−1
@@ -133,8 +133,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133133 c.x = msg.X
134134 c.y = msg.Y
135135 items := []util.Model{}
136+ t := styles.CurrentTheme()
136137 for _, completion := range msg.Completions {
137− item := NewCompletionItem(completion.Title, completion.Value)
138+ item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
138139 items = append(items, item)
139140 }
140141 c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
internal/tui/components/completions/item.go+35−12
@@ -1,13 +1,14 @@
11package completions
22
33import (
4+ "image/color"
5+
46 tea "github.com/charmbracelet/bubbletea/v2"
57 "github.com/charmbracelet/lipgloss/v2"
68 "github.com/charmbracelet/x/ansi"
79 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
810 "github.com/opencode-ai/opencode/internal/tui/layout"
911 "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
1112 "github.com/opencode-ai/opencode/internal/tui/util"
1213 "github.com/rivo/uniseg"
1314)
@@ -27,16 +28,35 @@ type completionItemCmp struct {
2728 value any
2829 focus bool
2930 matchIndexes []int
31+ bgColor color.Color
32+}
33+
34+type completionOptions func(*completionItemCmp)
35+
36+func WithBackgroundColor(c color.Color) completionOptions {
37+ return func(cmp *completionItemCmp) {
38+ cmp.bgColor = c
39+ }
3040}
3141
32−func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
33− return &completionItemCmp{
34− text: text,
35− value: value,
36− matchIndexes: matchIndexes,
42+func WithMatchIndexes(indexes ...int) completionOptions {
43+ return func(cmp *completionItemCmp) {
44+ cmp.matchIndexes = indexes
3745 }
3846}
3947
48+func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
49+ c := &completionItemCmp{
50+ text: text,
51+ value: value,
52+ }
53+
54+ for _, opt := range opts {
55+ opt(c)
56+ }
57+ return c
58+}
59+
4060// Init implements CommandItem.
4161func (c *completionItemCmp) Init() tea.Cmd {
4262 return nil
@@ -49,15 +69,18 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
4969
5070// View implements CommandItem.
5171func (c *completionItemCmp) View() tea.View {
52− t := theme.CurrentTheme()
72+ t := styles.CurrentTheme()
5373
54− baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
55− titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
56− titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
74+ titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
75+ titleMatchStyle := t.S().Text.Underline(true)
76+ if c.bgColor != nil {
77+ titleStyle = titleStyle.Background(c.bgColor)
78+ titleMatchStyle = titleMatchStyle.Background(c.bgColor)
79+ }
5780
5881 if c.focus {
59− titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
60− titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
82+ titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
83+ titleMatchStyle = t.S().TextSelected.Underline(true)
6184 }
6285
6386 var truncatedTitle string
internal/tui/components/core/helpers.go+15−2
@@ -9,13 +9,26 @@ import (
99 "github.com/opencode-ai/opencode/internal/tui/styles"
1010)
1111
12−func Section(title string, width int) string {
12+func Section(text string, width int) string {
1313 t := styles.CurrentTheme()
1414 char := "─"
15+ length := len(text) + 1
16+ remainingWidth := width - length
17+ if remainingWidth > 0 {
18+ text = text + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
19+ }
20+ return text
21+}
22+
23+func Title(title string, width int) string {
24+ t := styles.CurrentTheme()
25+ char := "╱"
1526 length := len(title) + 1
1627 remainingWidth := width - length
28+ lineStyle := t.S().Base.Foreground(t.Primary)
29+ titleStyle := t.S().Base.Foreground(t.Secondary)
1730 if remainingWidth > 0 {
18− title = title + " " + t.S().Base.Foreground(t.Border).Render(strings.Repeat(char, remainingWidth))
31+ title = titleStyle.Render(title) + " " + lineStyle.Render(strings.Repeat(char, remainingWidth))
1932 }
2033 return title
2134}
internal/tui/components/core/list/list.go+48−17
@@ -39,6 +39,7 @@ type ListModel interface {
3939 ResetView() // Clear rendering cache and reset scroll position
4040 Items() []util.Model // Get all items in the list
4141 SelectedIndex() int // Get the index of the currently selected item
42+ SetSelected(int) tea.Cmd // Set the selected item by index and scroll to it
4243 Filter(string) tea.Cmd // Filter items based on a search term
4344}
4445
@@ -133,11 +134,12 @@ type model struct {
133134 gapSize int // Number of empty lines between items
134135 padding []int // Padding around the list content
135136
136− filterable bool // Whether items can be filtered
137− filteredItems []util.Model // Filtered items based on current search
138− input textinput.Model // Input field for filtering items
139− hideFilterInput bool // Whether to hide the filter input field
140− currentSearch string // Current search term for filtering
137+ filterable bool // Whether items can be filtered
138+ filterPlaceholder string // Placeholder text for filter input
139+ filteredItems []util.Model // Filtered items based on current search
140+ input textinput.Model // Input field for filtering items
141+ hideFilterInput bool // Whether to hide the filter input field
142+ currentSearch string // Current search term for filtering
141143}
142144
143145// listOptions is a function type for configuring list options.
@@ -195,29 +197,39 @@ func WithHideFilterInput(hide bool) listOptions {
195197 }
196198}
197199
200+// WithFilterPlaceholder sets the placeholder text for the filter input field.
201+func WithFilterPlaceholder(placeholder string) listOptions {
202+ return func(m *model) {
203+ m.filterPlaceholder = placeholder
204+ }
205+}
206+
198207// New creates a new list model with the specified options.
199208// The list starts with no items selected and requires SetItems to be called
200209// or items to be provided via WithItems option.
201210func New(opts ...listOptions) ListModel {
202211 m := &model{
203− help: help.New(),
204− keyMap: DefaultKeyMap(),
205− allItems: []util.Model{},
206− filteredItems: []util.Model{},
207− renderState: newRenderState(),
208− gapSize: DefaultGapSize,
209− padding: []int{},
210− selectionState: selectionState{selectedIndex: NoSelection},
212+ help: help.New(),
213+ keyMap: DefaultKeyMap(),
214+ allItems: []util.Model{},
215+ filteredItems: []util.Model{},
216+ renderState: newRenderState(),
217+ gapSize: DefaultGapSize,
218+ padding: []int{},
219+ selectionState: selectionState{selectedIndex: NoSelection},
220+ filterPlaceholder: "Type to filter...",
211221 }
212222 for _, opt := range opts {
213223 opt(m)
214224 }
215225
216226 if m.filterable && !m.hideFilterInput {
227+ t := styles.CurrentTheme()
217228 ti := textinput.New()
218− ti.Placeholder = "Type to filter..."
229+ ti.Placeholder = m.filterPlaceholder
219230 ti.SetVirtualCursor(false)
220231 ti.Focus()
232+ ti.SetStyles(t.S().TextInput)
221233 m.input = ti
222234
223235 // disable j,k movements
@@ -616,7 +628,7 @@ func (m *model) isSectionHeader(index int) bool {
616628
617629// findFirstSelectableItem finds the first item that is not a section header.
618630func (m *model) findFirstSelectableItem() int {
619− for i := 0; i < len(m.filteredItems); i++ {
631+ for i := range m.filteredItems {
620632 if !m.isSectionHeader(i) {
621633 return i
622634 }
@@ -944,7 +956,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
944956 m.viewState.width = width
945957 m.ResetView()
946958 if m.filterable && !m.hideFilterInput {
947− m.input.SetWidth(m.getItemWidth() - 3)
959+ m.input.SetWidth(m.getItemWidth() - 5)
948960 }
949961 return m.setAllItemsSize()
950962}
@@ -1096,7 +1108,7 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
10961108}
10971109
10981110func (c *model) inputStyle() lipgloss.Style {
1099− return styles.BaseStyle()
1111+ return styles.BaseStyle().Padding(0, 1, 1, 1)
11001112}
11011113
11021114// section represents a group of items under a section header.
@@ -1275,3 +1287,22 @@ func (m *model) SelectedIndex() int {
12751287 }
12761288 return m.selectionState.selectedIndex
12771289}
1290+
1291+// SetSelected sets the selected item by index and automatically scrolls to make it visible.
1292+// If the index is invalid or points to a section header, it finds the nearest selectable item.
1293+func (m *model) SetSelected(index int) tea.Cmd {
1294+ changeNeeded := m.selectionState.selectedIndex - index
1295+ cmds := []tea.Cmd{}
1296+ if changeNeeded < 0 {
1297+ for range -changeNeeded {
1298+ cmds = append(cmds, m.selectNextItem())
1299+ m.renderVisible()
1300+ }
1301+ } else if changeNeeded > 0 {
1302+ for range changeNeeded {
1303+ cmds = append(cmds, m.selectPreviousItem())
1304+ m.renderVisible()
1305+ }
1306+ }
1307+ return tea.Batch(cmds...)
1308+}
internal/tui/components/dialogs/commands/arguments.go+7−6
@@ -220,9 +220,9 @@ func (c *commandArgumentsDialogCmp) View() tea.View {
220220}
221221
222222func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
223− offset := 13 + (1+c.focusIndex)*3
223+ row, col := c.Position()
224+ offset := row + 3 + (1+c.focusIndex)*3
224225 cursor.Y += offset
225− _, col := c.Position()
226226 cursor.X = cursor.X + col + 3
227227 return cursor
228228}
@@ -237,10 +237,11 @@ func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
237237 BorderForeground(t.TextMuted())
238238}
239239
240−func (q *commandArgumentsDialogCmp) Position() (int, int) {
241− row := 10
242− col := q.wWidth / 2
243− col -= q.width / 2
240+func (c *commandArgumentsDialogCmp) Position() (int, int) {
241+ row := c.wHeight / 2
242+ row -= c.wHeight / 2
243+ col := c.wWidth / 2
244+ col -= c.width / 2
244245 return row, col
245246}
246247
internal/tui/components/dialogs/commands/keys.go+5−5
@@ -14,16 +14,16 @@ type CommandsDialogKeyMap struct {
1414func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
1515 return CommandsDialogKeyMap{
1616 Select: key.NewBinding(
17− key.WithKeys("enter"),
17+ key.WithKeys("enter", "tab", "ctrl+y"),
1818 key.WithHelp("enter", "confirm"),
1919 ),
2020 Next: key.NewBinding(
21− key.WithKeys("tab", "down"),
22− key.WithHelp("tab/↓", "next"),
21+ key.WithKeys("down", "ctrl+n"),
22+ key.WithHelp("↓", "next item"),
2323 ),
2424 Previous: key.NewBinding(
25− key.WithKeys("shift+tab", "up"),
26− key.WithHelp("shift+tab/↑", "previous"),
25+ key.WithKeys("up", "ctrl+p"),
26+ key.WithHelp("↑", "previous item"),
2727 ),
2828 }
2929}
internal/tui/components/dialogs/sessions/keys.gonew file+56
@@ -0,0 +1,56 @@
1+package sessions
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/sessions/sessions.gonew file+172
@@ -0,0 +1,172 @@
1+package sessions
2+
3+import (
4+ "github.com/charmbracelet/bubbles/v2/help"
5+ "github.com/charmbracelet/bubbles/v2/key"
6+ tea "github.com/charmbracelet/bubbletea/v2"
7+ "github.com/charmbracelet/lipgloss/v2"
8+ "github.com/opencode-ai/opencode/internal/session"
9+ "github.com/opencode-ai/opencode/internal/tui/components/chat"
10+ "github.com/opencode-ai/opencode/internal/tui/components/completions"
11+ "github.com/opencode-ai/opencode/internal/tui/components/core"
12+ "github.com/opencode-ai/opencode/internal/tui/components/core/list"
13+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
14+ "github.com/opencode-ai/opencode/internal/tui/styles"
15+ "github.com/opencode-ai/opencode/internal/tui/util"
16+)
17+
18+const id dialogs.DialogID = "sessions"
19+
20+// SessionDialog interface for the session switching dialog
21+type SessionDialog interface {
22+ dialogs.DialogModel
23+}
24+
25+type sessionDialogCmp struct {
26+ selectedInx int
27+ wWidth int
28+ wHeight int
29+ width int
30+ selectedSessionID string
31+ keyMap KeyMap
32+ sessionsList list.ListModel
33+ renderedSelected bool
34+ help help.Model
35+}
36+
37+// NewSessionDialogCmp creates a new session switching dialog
38+func NewSessionDialogCmp(sessions []session.Session, selectedID string) SessionDialog {
39+ t := styles.CurrentTheme()
40+ listKeyMap := list.DefaultKeyMap()
41+ keyMap := DefaultKeyMap()
42+
43+ listKeyMap.Down.SetEnabled(false)
44+ listKeyMap.Up.SetEnabled(false)
45+ listKeyMap.NDown.SetEnabled(false)
46+ listKeyMap.NUp.SetEnabled(false)
47+ listKeyMap.HalfPageDown.SetEnabled(false)
48+ listKeyMap.HalfPageUp.SetEnabled(false)
49+ listKeyMap.Home.SetEnabled(false)
50+ listKeyMap.End.SetEnabled(false)
51+
52+ listKeyMap.DownOneItem = keyMap.Next
53+ listKeyMap.UpOneItem = keyMap.Previous
54+
55+ selectedInx := 0
56+ items := make([]util.Model, len(sessions))
57+ if len(sessions) > 0 {
58+ for i, session := range sessions {
59+ items[i] = completions.NewCompletionItem(session.Title, session)
60+ if session.ID == selectedID {
61+ selectedInx = i
62+ }
63+ }
64+ }
65+
66+ sessionsList := list.New(list.WithFilterable(true), list.WithFilterPlaceholder("Enter a session name"), list.WithKeyMap(listKeyMap), list.WithItems(items))
67+ help := help.New()
68+ help.Styles = t.S().Help
69+ s := &sessionDialogCmp{
70+ selectedInx: selectedInx,
71+ selectedSessionID: selectedID,
72+ keyMap: DefaultKeyMap(),
73+ sessionsList: sessionsList,
74+ help: help,
75+ }
76+
77+ return s
78+}
79+
80+func (s *sessionDialogCmp) Init() tea.Cmd {
81+ return s.sessionsList.Init()
82+}
83+
84+func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
85+ switch msg := msg.(type) {
86+ case tea.WindowSizeMsg:
87+ s.wWidth = msg.Width
88+ s.wHeight = msg.Height
89+ s.width = s.wWidth / 2
90+ var cmds []tea.Cmd
91+ cmds = append(cmds, s.sessionsList.SetSize(s.listWidth(), s.listHeight()))
92+ if !s.renderedSelected {
93+ cmds = append(cmds, s.sessionsList.SetSelected(s.selectedInx))
94+ s.renderedSelected = true
95+ }
96+ return s, tea.Sequence(cmds...)
97+ case tea.KeyPressMsg:
98+ switch {
99+ case key.Matches(msg, s.keyMap.Select):
100+ if len(s.sessionsList.Items()) > 0 {
101+ items := s.sessionsList.Items()
102+ selectedItemInx := s.sessionsList.SelectedIndex()
103+ return s, tea.Sequence(
104+ util.CmdHandler(dialogs.CloseDialogMsg{}),
105+ util.CmdHandler(
106+ chat.SessionSelectedMsg(items[selectedItemInx].(completions.CompletionItem).Value().(session.Session)),
107+ ),
108+ )
109+ }
110+ default:
111+ u, cmd := s.sessionsList.Update(msg)
112+ s.sessionsList = u.(list.ListModel)
113+ return s, cmd
114+ }
115+ }
116+ return s, nil
117+}
118+
119+func (s *sessionDialogCmp) View() tea.View {
120+ t := styles.CurrentTheme()
121+ listView := s.sessionsList.View()
122+ content := lipgloss.JoinVertical(
123+ lipgloss.Left,
124+ t.S().Base.Padding(0, 1, 1, 1).Render(core.Title("Switch Session", s.width-4)),
125+ listView.String(),
126+ "",
127+ t.S().Base.Width(s.width-2).PaddingRight(2).AlignHorizontal(lipgloss.Right).Render(s.help.View(s.keyMap)),
128+ )
129+
130+ v := tea.NewView(s.style().Render(content))
131+ if listView.Cursor() != nil {
132+ c := s.moveCursor(listView.Cursor())
133+ v.SetCursor(c)
134+ }
135+ return v
136+}
137+
138+func (s *sessionDialogCmp) style() lipgloss.Style {
139+ t := styles.CurrentTheme()
140+ return t.S().Base.
141+ Width(s.width).
142+ Border(lipgloss.RoundedBorder()).
143+ BorderForeground(t.BorderFocus)
144+}
145+
146+func (s *sessionDialogCmp) listHeight() int {
147+ return s.wHeight/2 - 6 // 5 for the border, title and help
148+}
149+
150+func (s *sessionDialogCmp) listWidth() int {
151+ return s.width - 2 // 2 for the border
152+}
153+
154+func (s *sessionDialogCmp) Position() (int, int) {
155+ row := s.wHeight/4 - 2 // just a bit above the center
156+ col := s.wWidth / 2
157+ col -= s.width / 2
158+ return row, col
159+}
160+
161+func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
162+ row, col := s.Position()
163+ offset := row + 3 // Border + title
164+ cursor.Y += offset
165+ cursor.X = cursor.X + col + 2
166+ return cursor
167+}
168+
169+// ID implements SessionDialog.
170+func (s *sessionDialogCmp) ID() dialogs.DialogID {
171+ return id
172+}
internal/tui/layout/layout.go+5
@@ -3,6 +3,7 @@ package layout
33import (
44 "reflect"
55
6+ "github.com/charmbracelet/bubbles/v2/help"
67 "github.com/charmbracelet/bubbles/v2/key"
78 tea "github.com/charmbracelet/bubbletea/v2"
89)
@@ -26,6 +27,10 @@ type Positionable interface {
2627 SetPosition(x, y int) tea.Cmd
2728}
2829
30+type Help interface {
31+ Help() help.KeyMap
32+}
33+
2934func KeyMapToSlice(t any) (bindings []key.Binding) {
3035 typ := reflect.TypeOf(t)
3136 if typ.Kind() != reflect.Struct {
internal/tui/styles/crush.go+19−16
@@ -10,30 +10,33 @@ func NewCrushTheme() *Theme {
1010 Name: "crush",
1111 IsDark: true,
1212
13− Primary: charmtone.Charple,
14− Secondary: charmtone.Dolly,
15− Tertiary: charmtone.Bok,
16− Accent: charmtone.Zest,
13+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
14+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
15+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
16+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
17+
18+ PrimaryLight: lipgloss.Color(charmtone.Hazy.Hex()),
1719
1820 // Backgrounds
19− BgBase: charmtone.Pepper,
20− BgSubtle: charmtone.Charcoal,
21− BgOverlay: charmtone.Iron,
21+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
22+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
23+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
2224
2325 // Foregrounds
24− FgBase: charmtone.Ash,
25− FgMuted: charmtone.Squid,
26− FgSubtle: charmtone.Oyster,
26+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
27+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
28+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
29+ FgSelected: lipgloss.Color(charmtone.Salt.Hex()),
2730
2831 // Borders
29− Border: charmtone.Charcoal,
30− BorderFocus: charmtone.Charple,
32+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
33+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
3134
3235 // Status
33− Success: charmtone.Guac,
34− Error: charmtone.Sriracha,
35− Warning: charmtone.Uni,
36− Info: charmtone.Malibu,
36+ Success: lipgloss.Color(charmtone.Guac.Hex()),
37+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
38+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
39+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
3740
3841 // TODO: fix this.
3942 SyntaxBg: lipgloss.Color("#1C1C1F"),
internal/tui/styles/theme.go+55−12
@@ -4,7 +4,9 @@ import (
44 "fmt"
55 "image/color"
66
7+ "github.com/charmbracelet/bubbles/v2/help"
78 "github.com/charmbracelet/bubbles/v2/textarea"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
810 tea "github.com/charmbracelet/bubbletea/v2"
911 "github.com/charmbracelet/glamour/v2/ansi"
1012 "github.com/charmbracelet/lipgloss/v2"
@@ -25,13 +27,16 @@ type Theme struct {
2527 Tertiary color.Color
2628 Accent color.Color
2729
30+ PrimaryLight color.Color
31+
2832 BgBase color.Color
2933 BgSubtle color.Color
3034 BgOverlay color.Color
3135
32− FgBase color.Color
33− FgMuted color.Color
34− FgSubtle color.Color
36+ FgBase color.Color
37+ FgMuted color.Color
38+ FgSubtle color.Color
39+ FgSelected color.Color
3540
3641 Border color.Color
3742 BorderFocus color.Color
@@ -51,24 +56,30 @@ type Theme struct {
5156}
5257
5358type Styles struct {
54− Base lipgloss.Style
59+ Base lipgloss.Style
60+ SelectedBase lipgloss.Style
5561
56− Title lipgloss.Style
57− Subtitle lipgloss.Style
58− Text lipgloss.Style
59− Muted lipgloss.Style
60− Subtle lipgloss.Style
62+ Title lipgloss.Style
63+ Subtitle lipgloss.Style
64+ Text lipgloss.Style
65+ TextSelected lipgloss.Style
66+ Muted lipgloss.Style
67+ Subtle lipgloss.Style
6168
6269 Success lipgloss.Style
6370 Error lipgloss.Style
6471 Warning lipgloss.Style
6572 Info lipgloss.Style
66− // Markdown & Chroma
6773
74+ // Markdown & Chroma
6875 Markdown ansi.StyleConfig
6976
7077 // Inputs
71− TextArea textarea.Styles
78+ TextInput textinput.Styles
79+ TextArea textarea.Styles
80+
81+ // Help
82+ Help help.Styles
7283}
7384
7485func (t *Theme) S() *Styles {
@@ -84,6 +95,8 @@ func (t *Theme) buildStyles() *Styles {
8495 return &Styles{
8596 Base: base,
8697
98+ SelectedBase: base.Background(t.Primary),
99+
87100 Title: base.
88101 Foreground(t.Accent).
89102 Bold(true),
@@ -92,7 +105,8 @@ func (t *Theme) buildStyles() *Styles {
92105 Foreground(t.Secondary).
93106 Bold(true),
94107
95− Text: base,
108+ Text: base,
109+ TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
96110
97111 Muted: base.Foreground(t.FgMuted),
98112
@@ -106,6 +120,25 @@ func (t *Theme) buildStyles() *Styles {
106120
107121 Info: base.Foreground(t.Info),
108122
123+ TextInput: textinput.Styles{
124+ Focused: textinput.StyleState{
125+ Text: base,
126+ Placeholder: base.Foreground(t.FgMuted),
127+ Prompt: base.Foreground(t.Tertiary),
128+ Suggestion: base.Foreground(t.FgMuted),
129+ },
130+ Blurred: textinput.StyleState{
131+ Text: base.Foreground(t.FgMuted),
132+ Placeholder: base.Foreground(t.FgMuted),
133+ Prompt: base.Foreground(t.FgMuted),
134+ Suggestion: base.Foreground(t.FgMuted),
135+ },
136+ Cursor: textinput.CursorStyle{
137+ Color: t.Secondary,
138+ Shape: tea.CursorBar,
139+ Blink: true,
140+ },
141+ },
109142 TextArea: textarea.Styles{
110143 Focused: textarea.StyleState{
111144 Base: base,
@@ -341,6 +374,16 @@ func (t *Theme) buildStyles() *Styles {
341374 BlockPrefix: "\n ",
342375 },
343376 },
377+
378+ Help: help.Styles{
379+ ShortKey: base.Foreground(t.FgMuted),
380+ ShortDesc: base.Foreground(t.FgSubtle),
381+ ShortSeparator: base.Foreground(t.Border),
382+ Ellipsis: base.Foreground(t.Border),
383+ FullKey: base.Foreground(t.FgMuted),
384+ FullDesc: base.Foreground(t.FgSubtle),
385+ FullSeparator: base.Foreground(t.Border),
386+ },
344387 }
345388}
346389
internal/tui/tui.go+17−1
@@ -1,17 +1,21 @@
11package tui
22
33import (
4+ "context"
5+
46 "github.com/charmbracelet/bubbles/v2/key"
57 tea "github.com/charmbracelet/bubbletea/v2"
68 "github.com/charmbracelet/lipgloss/v2"
79 "github.com/opencode-ai/opencode/internal/app"
810 "github.com/opencode-ai/opencode/internal/logging"
911 "github.com/opencode-ai/opencode/internal/pubsub"
12+ cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
1013 "github.com/opencode-ai/opencode/internal/tui/components/completions"
1114 "github.com/opencode-ai/opencode/internal/tui/components/core"
1215 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
1316 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
1417 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
18+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
1519 "github.com/opencode-ai/opencode/internal/tui/layout"
1620 "github.com/opencode-ai/opencode/internal/tui/page"
1721 "github.com/opencode-ai/opencode/internal/tui/page/chat"
@@ -35,6 +39,9 @@ type appModel struct {
3539
3640 dialog dialogs.DialogCmp
3741 completions completions.Completions
42+
43+ // Session
44+ selectedSessionID string // The ID of the currently selected session
3845}
3946
4047// Init initializes the application model and returns initial commands.
@@ -90,6 +97,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9097 cmds = append(cmds, statusCmd)
9198 return a, tea.Batch(cmds...)
9299
100+ // Session
101+ case cmpChat.SessionSelectedMsg:
102+ a.selectedSessionID = msg.ID
93103 // Logs
94104 case pubsub.Event[logging.LogMessage]:
95105 // Send to the status component
@@ -170,7 +180,13 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
170180 return util.CmdHandler(dialogs.OpenDialogMsg{
171181 Model: commands.NewCommandDialog(),
172182 })
173−
183+ case key.Matches(msg, a.keyMap.SwitchSession):
184+ return func() tea.Msg {
185+ allSessions, _ := a.app.Sessions.List(context.Background())
186+ return dialogs.OpenDialogMsg{
187+ Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
188+ }
189+ }
174190 // Page navigation
175191 case key.Matches(msg, a.keyMap.Logs):
176192 return a.moveToPage(page.LogsPage)