on main help sign in
sessions dialog
1b178f5Kujtim Hoxha
internal/tui/components/completions/completions.go+2−1
@@ -133,8 +133,9 @@ func (c *completionsCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
133 c.x = msg.X
133 c.x = msg.X
134 c.y = msg.Y
134 c.y = msg.Y
135 items := []util.Model{}
135 items := []util.Model{}
136+ t := styles.CurrentTheme()
136 for _, completion := range msg.Completions {
137 for _, completion := range msg.Completions {
137− item := NewCompletionItem(completion.Title, completion.Value)
138+ item := NewCompletionItem(completion.Title, completion.Value, WithBackgroundColor(t.BgSubtle))
138 items = append(items, item)
139 items = append(items, item)
139 }
140 }
140 c.height = max(min(10, len(items)), 1) // Ensure at least 1 item height
141 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 @@
1package completions
1package completions
2
2
3import (
3import (
4+ "image/color"
5+
4 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
5 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
6 "github.com/charmbracelet/x/ansi"
8 "github.com/charmbracelet/x/ansi"
7 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
9 "github.com/opencode-ai/opencode/internal/tui/components/core/list"
8 "github.com/opencode-ai/opencode/internal/tui/layout"
10 "github.com/opencode-ai/opencode/internal/tui/layout"
9 "github.com/opencode-ai/opencode/internal/tui/styles"
11 "github.com/opencode-ai/opencode/internal/tui/styles"
10− "github.com/opencode-ai/opencode/internal/tui/theme"
11 "github.com/opencode-ai/opencode/internal/tui/util"
12 "github.com/opencode-ai/opencode/internal/tui/util"
12 "github.com/rivo/uniseg"
13 "github.com/rivo/uniseg"
13)
14)
@@ -27,16 +28,35 @@ type completionItemCmp struct {
27 value any
28 value any
28 focus bool
29 focus bool
29 matchIndexes []int
30 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+ }
30}
40}
31
41
32−func NewCompletionItem(text string, value any, matchIndexes ...int) CompletionItem {
42+func WithMatchIndexes(indexes ...int) completionOptions {
33− return &completionItemCmp{
43+ return func(cmp *completionItemCmp) {
34− text: text,
35− value: value,
36− matchIndexes: matchIndexes,
44+ cmp.matchIndexes = indexes
37 }
45 }
38}
46}
39
47
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+
40// Init implements CommandItem.
60// Init implements CommandItem.
41func (c *completionItemCmp) Init() tea.Cmd {
61func (c *completionItemCmp) Init() tea.Cmd {
42 return nil
62 return nil
@@ -49,15 +69,18 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
49
69
50// View implements CommandItem.
70// View implements CommandItem.
51func (c *completionItemCmp) View() tea.View {
71func (c *completionItemCmp) View() tea.View {
52− t := theme.CurrentTheme()
72+ t := styles.CurrentTheme()
53
73
54− baseStyle := styles.BaseStyle().Background(t.BackgroundSecondary())
55− titleStyle := baseStyle.Padding(0, 1).Width(c.width).Foreground(t.Text())
74+ titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
56− titleMatchStyle := baseStyle.Foreground(t.Text()).Underline(true)
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+ }
57
80
58 if c.focus {
81 if c.focus {
59− titleStyle = titleStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
82+ titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
60− titleMatchStyle = titleMatchStyle.Foreground(t.Background()).Background(t.Primary()).Bold(true)
83+ titleMatchStyle = t.S().TextSelected.Underline(true)
61 }
84 }
62
85
63 var truncatedTitle string
86 var truncatedTitle string
internal/tui/components/core/helpers.go+15−2
@@ -9,13 +9,26 @@ import (
9 "github.com/opencode-ai/opencode/internal/tui/styles"
9 "github.com/opencode-ai/opencode/internal/tui/styles"
10)
10)
11
11
12−func Section(title string, width int) string {
12+func Section(text string, width int) string {
13 t := styles.CurrentTheme()
13 t := styles.CurrentTheme()
14 char := "─"
14 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 := "╱"
15 length := len(title) + 1
26 length := len(title) + 1
16 remainingWidth := width - length
27 remainingWidth := width - length
28+ lineStyle := t.S().Base.Foreground(t.Primary)
29+ titleStyle := t.S().Base.Foreground(t.Secondary)
17 if remainingWidth > 0 {
30 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))
19 }
32 }
20 return title
33 return title
21}
34}
internal/tui/components/core/list/list.go+48−17
@@ -39,6 +39,7 @@ type ListModel interface {
39 ResetView() // Clear rendering cache and reset scroll position
39 ResetView() // Clear rendering cache and reset scroll position
40 Items() []util.Model // Get all items in the list
40 Items() []util.Model // Get all items in the list
41 SelectedIndex() int // Get the index of the currently selected item
41 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
42 Filter(string) tea.Cmd // Filter items based on a search term
43 Filter(string) tea.Cmd // Filter items based on a search term
43}
44}
44
45
@@ -133,11 +134,12 @@ type model struct {
133 gapSize int // Number of empty lines between items
134 gapSize int // Number of empty lines between items
134 padding []int // Padding around the list content
135 padding []int // Padding around the list content
135
136
136− filterable bool // Whether items can be filtered
137+ filterable bool // Whether items can be filtered
138+ filterPlaceholder string // Placeholder text for filter input
137− filteredItems []util.Model // Filtered items based on current search
139+ filteredItems []util.Model // Filtered items based on current search
138− input textinput.Model // Input field for filtering items
140+ input textinput.Model // Input field for filtering items
139− hideFilterInput bool // Whether to hide the filter input field
141+ hideFilterInput bool // Whether to hide the filter input field
140− currentSearch string // Current search term for filtering
142+ currentSearch string // Current search term for filtering
141}
143}
142
144
143// listOptions is a function type for configuring list options.
145// listOptions is a function type for configuring list options.
@@ -195,29 +197,39 @@ func WithHideFilterInput(hide bool) listOptions {
195 }
197 }
196}
198}
197
199
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+
198// New creates a new list model with the specified options.
207// New creates a new list model with the specified options.
199// The list starts with no items selected and requires SetItems to be called
208// The list starts with no items selected and requires SetItems to be called
200// or items to be provided via WithItems option.
209// or items to be provided via WithItems option.
201func New(opts ...listOptions) ListModel {
210func New(opts ...listOptions) ListModel {
202 m := &model{
211 m := &model{
203− help: help.New(),
212+ help: help.New(),
204− keyMap: DefaultKeyMap(),
213+ keyMap: DefaultKeyMap(),
205− allItems: []util.Model{},
214+ allItems: []util.Model{},
206− filteredItems: []util.Model{},
215+ filteredItems: []util.Model{},
207− renderState: newRenderState(),
216+ renderState: newRenderState(),
208− gapSize: DefaultGapSize,
217+ gapSize: DefaultGapSize,
209− padding: []int{},
218+ padding: []int{},
210− selectionState: selectionState{selectedIndex: NoSelection},
219+ selectionState: selectionState{selectedIndex: NoSelection},
220+ filterPlaceholder: "Type to filter...",
211 }
221 }
212 for _, opt := range opts {
222 for _, opt := range opts {
213 opt(m)
223 opt(m)
214 }
224 }
215
225
216 if m.filterable && !m.hideFilterInput {
226 if m.filterable && !m.hideFilterInput {
227+ t := styles.CurrentTheme()
217 ti := textinput.New()
228 ti := textinput.New()
218− ti.Placeholder = "Type to filter..."
229+ ti.Placeholder = m.filterPlaceholder
219 ti.SetVirtualCursor(false)
230 ti.SetVirtualCursor(false)
220 ti.Focus()
231 ti.Focus()
232+ ti.SetStyles(t.S().TextInput)
221 m.input = ti
233 m.input = ti
222
234
223 // disable j,k movements
235 // disable j,k movements
@@ -616,7 +628,7 @@ func (m *model) isSectionHeader(index int) bool {
616
628
617// findFirstSelectableItem finds the first item that is not a section header.
629// findFirstSelectableItem finds the first item that is not a section header.
618func (m *model) findFirstSelectableItem() int {
630func (m *model) findFirstSelectableItem() int {
619− for i := 0; i < len(m.filteredItems); i++ {
631+ for i := range m.filteredItems {
620 if !m.isSectionHeader(i) {
632 if !m.isSectionHeader(i) {
621 return i
633 return i
622 }
634 }
@@ -944,7 +956,7 @@ func (m *model) SetSize(width int, height int) tea.Cmd {
944 m.viewState.width = width
956 m.viewState.width = width
945 m.ResetView()
957 m.ResetView()
946 if m.filterable && !m.hideFilterInput {
958 if m.filterable && !m.hideFilterInput {
947− m.input.SetWidth(m.getItemWidth() - 3)
959+ m.input.SetWidth(m.getItemWidth() - 5)
948 }
960 }
949 return m.setAllItemsSize()
961 return m.setAllItemsSize()
950}
962}
@@ -1096,7 +1108,7 @@ func (m *model) SetItems(items []util.Model) tea.Cmd {
1096}
1108}
1097
1109
1098func (c *model) inputStyle() lipgloss.Style {
1110func (c *model) inputStyle() lipgloss.Style {
1099− return styles.BaseStyle()
1111+ return styles.BaseStyle().Padding(0, 1, 1, 1)
1100}
1112}
1101
1113
1102// section represents a group of items under a section header.
1114// section represents a group of items under a section header.
@@ -1275,3 +1287,22 @@ func (m *model) SelectedIndex() int {
1275 }
1287 }
1276 return m.selectionState.selectedIndex
1288 return m.selectionState.selectedIndex
1277}
1289}
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 {
220}
220}
221
221
222func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
222func (c *commandArgumentsDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
223+ row, col := c.Position()
223− offset := 13 + (1+c.focusIndex)*3
224+ offset := row + 3 + (1+c.focusIndex)*3
224 cursor.Y += offset
225 cursor.Y += offset
225− _, col := c.Position()
226 cursor.X = cursor.X + col + 3
226 cursor.X = cursor.X + col + 3
227 return cursor
227 return cursor
228}
228}
@@ -237,10 +237,11 @@ func (c *commandArgumentsDialogCmp) style() lipgloss.Style {
237 BorderForeground(t.TextMuted())
237 BorderForeground(t.TextMuted())
238}
238}
239
239
240−func (q *commandArgumentsDialogCmp) Position() (int, int) {
240+func (c *commandArgumentsDialogCmp) Position() (int, int) {
241− row := 10
241+ row := c.wHeight / 2
242+ row -= c.wHeight / 2
242− col := q.wWidth / 2
243+ col := c.wWidth / 2
243− col -= q.width / 2
244+ col -= c.width / 2
244 return row, col
245 return row, col
245}
246}
246
247
internal/tui/components/dialogs/commands/keys.go+5−5
@@ -14,16 +14,16 @@ type CommandsDialogKeyMap struct {
14func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
14func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
15 return CommandsDialogKeyMap{
15 return CommandsDialogKeyMap{
16 Select: key.NewBinding(
16 Select: key.NewBinding(
17− key.WithKeys("enter"),
17+ key.WithKeys("enter", "tab", "ctrl+y"),
18 key.WithHelp("enter", "confirm"),
18 key.WithHelp("enter", "confirm"),
19 ),
19 ),
20 Next: key.NewBinding(
20 Next: key.NewBinding(
21− key.WithKeys("tab", "down"),
21+ key.WithKeys("down", "ctrl+n"),
22− key.WithHelp("tab/↓", "next"),
22+ key.WithHelp("↓", "next item"),
23 ),
23 ),
24 Previous: key.NewBinding(
24 Previous: key.NewBinding(
25− key.WithKeys("shift+tab", "up"),
25+ key.WithKeys("up", "ctrl+p"),
26− key.WithHelp("shift+tab/↑", "previous"),
26+ key.WithHelp("↑", "previous item"),
27 ),
27 ),
28 }
28 }
29}
29}
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
3import (
3import (
4 "reflect"
4 "reflect"
5
5
6+ "github.com/charmbracelet/bubbles/v2/help"
6 "github.com/charmbracelet/bubbles/v2/key"
7 "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
8 tea "github.com/charmbracelet/bubbletea/v2"
8)
9)
@@ -26,6 +27,10 @@ type Positionable interface {
26 SetPosition(x, y int) tea.Cmd
27 SetPosition(x, y int) tea.Cmd
27}
28}
28
29
30+type Help interface {
31+ Help() help.KeyMap
32+}
33+
29func KeyMapToSlice(t any) (bindings []key.Binding) {
34func KeyMapToSlice(t any) (bindings []key.Binding) {
30 typ := reflect.TypeOf(t)
35 typ := reflect.TypeOf(t)
31 if typ.Kind() != reflect.Struct {
36 if typ.Kind() != reflect.Struct {
internal/tui/styles/crush.go+19−16
@@ -10,30 +10,33 @@ func NewCrushTheme() *Theme {
10 Name: "crush",
10 Name: "crush",
11 IsDark: true,
11 IsDark: true,
12
12
13− Primary: charmtone.Charple,
13+ Primary: lipgloss.Color(charmtone.Charple.Hex()),
14− Secondary: charmtone.Dolly,
14+ Secondary: lipgloss.Color(charmtone.Dolly.Hex()),
15− Tertiary: charmtone.Bok,
15+ Tertiary: lipgloss.Color(charmtone.Bok.Hex()),
16− Accent: charmtone.Zest,
16+ Accent: lipgloss.Color(charmtone.Zest.Hex()),
17+
18+ PrimaryLight: lipgloss.Color(charmtone.Hazy.Hex()),
17
19
18 // Backgrounds
20 // Backgrounds
19− BgBase: charmtone.Pepper,
21+ BgBase: lipgloss.Color(charmtone.Pepper.Hex()),
20− BgSubtle: charmtone.Charcoal,
22+ BgSubtle: lipgloss.Color(charmtone.Charcoal.Hex()),
21− BgOverlay: charmtone.Iron,
23+ BgOverlay: lipgloss.Color(charmtone.Iron.Hex()),
22
24
23 // Foregrounds
25 // Foregrounds
24− FgBase: charmtone.Ash,
26+ FgBase: lipgloss.Color(charmtone.Ash.Hex()),
25− FgMuted: charmtone.Squid,
27+ FgMuted: lipgloss.Color(charmtone.Squid.Hex()),
26− FgSubtle: charmtone.Oyster,
28+ FgSubtle: lipgloss.Color(charmtone.Oyster.Hex()),
29+ FgSelected: lipgloss.Color(charmtone.Salt.Hex()),
27
30
28 // Borders
31 // Borders
29− Border: charmtone.Charcoal,
32+ Border: lipgloss.Color(charmtone.Charcoal.Hex()),
30− BorderFocus: charmtone.Charple,
33+ BorderFocus: lipgloss.Color(charmtone.Charple.Hex()),
31
34
32 // Status
35 // Status
33− Success: charmtone.Guac,
36+ Success: lipgloss.Color(charmtone.Guac.Hex()),
34− Error: charmtone.Sriracha,
37+ Error: lipgloss.Color(charmtone.Sriracha.Hex()),
35− Warning: charmtone.Uni,
38+ Warning: lipgloss.Color(charmtone.Uni.Hex()),
36− Info: charmtone.Malibu,
39+ Info: lipgloss.Color(charmtone.Malibu.Hex()),
37
40
38 // TODO: fix this.
41 // TODO: fix this.
39 SyntaxBg: lipgloss.Color("#1C1C1F"),
42 SyntaxBg: lipgloss.Color("#1C1C1F"),
internal/tui/styles/theme.go+55−12
@@ -4,7 +4,9 @@ import (
4 "fmt"
4 "fmt"
5 "image/color"
5 "image/color"
6
6
7+ "github.com/charmbracelet/bubbles/v2/help"
7 "github.com/charmbracelet/bubbles/v2/textarea"
8 "github.com/charmbracelet/bubbles/v2/textarea"
9+ "github.com/charmbracelet/bubbles/v2/textinput"
8 tea "github.com/charmbracelet/bubbletea/v2"
10 tea "github.com/charmbracelet/bubbletea/v2"
9 "github.com/charmbracelet/glamour/v2/ansi"
11 "github.com/charmbracelet/glamour/v2/ansi"
10 "github.com/charmbracelet/lipgloss/v2"
12 "github.com/charmbracelet/lipgloss/v2"
@@ -25,13 +27,16 @@ type Theme struct {
25 Tertiary color.Color
27 Tertiary color.Color
26 Accent color.Color
28 Accent color.Color
27
29
30+ PrimaryLight color.Color
31+
28 BgBase color.Color
32 BgBase color.Color
29 BgSubtle color.Color
33 BgSubtle color.Color
30 BgOverlay color.Color
34 BgOverlay color.Color
31
35
32− FgBase color.Color
36+ FgBase color.Color
33− FgMuted color.Color
37+ FgMuted color.Color
38+ FgSubtle color.Color
34− FgSubtle color.Color
39+ FgSelected color.Color
35
40
36 Border color.Color
41 Border color.Color
37 BorderFocus color.Color
42 BorderFocus color.Color
@@ -51,24 +56,30 @@ type Theme struct {
51}
56}
52
57
53type Styles struct {
58type Styles struct {
59+ Base lipgloss.Style
54− Base lipgloss.Style
60+ SelectedBase lipgloss.Style
55
61
56− Title lipgloss.Style
62+ Title lipgloss.Style
63+ Subtitle lipgloss.Style
64+ Text lipgloss.Style
57− Subtitle lipgloss.Style
65+ TextSelected lipgloss.Style
58− Text lipgloss.Style
59− Muted lipgloss.Style
66+ Muted lipgloss.Style
60− Subtle lipgloss.Style
67+ Subtle lipgloss.Style
61
68
62 Success lipgloss.Style
69 Success lipgloss.Style
63 Error lipgloss.Style
70 Error lipgloss.Style
64 Warning lipgloss.Style
71 Warning lipgloss.Style
65 Info lipgloss.Style
72 Info lipgloss.Style
66− // Markdown & Chroma
67
73
74+ // Markdown & Chroma
68 Markdown ansi.StyleConfig
75 Markdown ansi.StyleConfig
69
76
70 // Inputs
77 // Inputs
78+ TextInput textinput.Styles
71− TextArea textarea.Styles
79+ TextArea textarea.Styles
80+
81+ // Help
82+ Help help.Styles
72}
83}
73
84
74func (t *Theme) S() *Styles {
85func (t *Theme) S() *Styles {
@@ -84,6 +95,8 @@ func (t *Theme) buildStyles() *Styles {
84 return &Styles{
95 return &Styles{
85 Base: base,
96 Base: base,
86
97
98+ SelectedBase: base.Background(t.Primary),
99+
87 Title: base.
100 Title: base.
88 Foreground(t.Accent).
101 Foreground(t.Accent).
89 Bold(true),
102 Bold(true),
@@ -92,7 +105,8 @@ func (t *Theme) buildStyles() *Styles {
92 Foreground(t.Secondary).
105 Foreground(t.Secondary).
93 Bold(true),
106 Bold(true),
94
107
95− Text: base,
108+ Text: base,
109+ TextSelected: base.Background(t.Primary).Foreground(t.FgSelected),
96
110
97 Muted: base.Foreground(t.FgMuted),
111 Muted: base.Foreground(t.FgMuted),
98
112
@@ -106,6 +120,25 @@ func (t *Theme) buildStyles() *Styles {
106
120
107 Info: base.Foreground(t.Info),
121 Info: base.Foreground(t.Info),
108
122
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+ },
109 TextArea: textarea.Styles{
142 TextArea: textarea.Styles{
110 Focused: textarea.StyleState{
143 Focused: textarea.StyleState{
111 Base: base,
144 Base: base,
@@ -341,6 +374,16 @@ func (t *Theme) buildStyles() *Styles {
341 BlockPrefix: "\n ",
374 BlockPrefix: "\n ",
342 },
375 },
343 },
376 },
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+ },
344 }
387 }
345}
388}
346
389
internal/tui/tui.go+17−1
@@ -1,17 +1,21 @@
1package tui
1package tui
2
2
3import (
3import (
4+ "context"
5+
4 "github.com/charmbracelet/bubbles/v2/key"
6 "github.com/charmbracelet/bubbles/v2/key"
5 tea "github.com/charmbracelet/bubbletea/v2"
7 tea "github.com/charmbracelet/bubbletea/v2"
6 "github.com/charmbracelet/lipgloss/v2"
8 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/opencode-ai/opencode/internal/app"
9 "github.com/opencode-ai/opencode/internal/app"
8 "github.com/opencode-ai/opencode/internal/logging"
10 "github.com/opencode-ai/opencode/internal/logging"
9 "github.com/opencode-ai/opencode/internal/pubsub"
11 "github.com/opencode-ai/opencode/internal/pubsub"
12+ cmpChat "github.com/opencode-ai/opencode/internal/tui/components/chat"
10 "github.com/opencode-ai/opencode/internal/tui/components/completions"
13 "github.com/opencode-ai/opencode/internal/tui/components/completions"
11 "github.com/opencode-ai/opencode/internal/tui/components/core"
14 "github.com/opencode-ai/opencode/internal/tui/components/core"
12 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
15 "github.com/opencode-ai/opencode/internal/tui/components/dialogs"
13 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
16 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/commands"
14 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
17 "github.com/opencode-ai/opencode/internal/tui/components/dialogs/quit"
18+ "github.com/opencode-ai/opencode/internal/tui/components/dialogs/sessions"
15 "github.com/opencode-ai/opencode/internal/tui/layout"
19 "github.com/opencode-ai/opencode/internal/tui/layout"
16 "github.com/opencode-ai/opencode/internal/tui/page"
20 "github.com/opencode-ai/opencode/internal/tui/page"
17 "github.com/opencode-ai/opencode/internal/tui/page/chat"
21 "github.com/opencode-ai/opencode/internal/tui/page/chat"
@@ -35,6 +39,9 @@ type appModel struct {
35
39
36 dialog dialogs.DialogCmp
40 dialog dialogs.DialogCmp
37 completions completions.Completions
41 completions completions.Completions
42+
43+ // Session
44+ selectedSessionID string // The ID of the currently selected session
38}
45}
39
46
40// Init initializes the application model and returns initial commands.
47// Init initializes the application model and returns initial commands.
@@ -90,6 +97,9 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
90 cmds = append(cmds, statusCmd)
97 cmds = append(cmds, statusCmd)
91 return a, tea.Batch(cmds...)
98 return a, tea.Batch(cmds...)
92
99
100+ // Session
101+ case cmpChat.SessionSelectedMsg:
102+ a.selectedSessionID = msg.ID
93 // Logs
103 // Logs
94 case pubsub.Event[logging.LogMessage]:
104 case pubsub.Event[logging.LogMessage]:
95 // Send to the status component
105 // Send to the status component
@@ -170,7 +180,13 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
170 return util.CmdHandler(dialogs.OpenDialogMsg{
180 return util.CmdHandler(dialogs.OpenDialogMsg{
171 Model: commands.NewCommandDialog(),
181 Model: commands.NewCommandDialog(),
172 })
182 })
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+ }
174 // Page navigation
190 // Page navigation
175 case key.Matches(msg, a.keyMap.Logs):
191 case key.Matches(msg, a.keyMap.Logs):
176 return a.moveToPage(page.LogsPage)
192 return a.moveToPage(page.LogsPage)