on main help sign in
add shortcuts
3311aedKujtim Hoxha
internal/tui/components/completions/item.go+49−38
@@ -29,23 +29,30 @@ type completionItemCmp struct {
29 focus bool
29 focus bool
30 matchIndexes []int
30 matchIndexes []int
31 bgColor color.Color
31 bgColor color.Color
32+ shortcut string
32}
33}
33
34
34−type completionOptions func(*completionItemCmp)
35+type CompletionOption func(*completionItemCmp)
35
36
36−func WithBackgroundColor(c color.Color) completionOptions {
37+func WithBackgroundColor(c color.Color) CompletionOption {
37 return func(cmp *completionItemCmp) {
38 return func(cmp *completionItemCmp) {
38 cmp.bgColor = c
39 cmp.bgColor = c
39 }
40 }
40}
41}
41
42
42−func WithMatchIndexes(indexes ...int) completionOptions {
43+func WithMatchIndexes(indexes ...int) CompletionOption {
43 return func(cmp *completionItemCmp) {
44 return func(cmp *completionItemCmp) {
44 cmp.matchIndexes = indexes
45 cmp.matchIndexes = indexes
45 }
46 }
46}
47}
47
48
49+func WithShortcut(shortcut string) CompletionOption {
50+ return func(cmp *completionItemCmp) {
51+ cmp.shortcut = shortcut
52+ }
53+}
54+
48−func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
55+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
49 c := &completionItemCmp{
56 c := &completionItemCmp{
50 text: text,
57 text: text,
51 value: value,
58 value: value,
@@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
71func (c *completionItemCmp) View() tea.View {
78func (c *completionItemCmp) View() tea.View {
72 t := styles.CurrentTheme()
79 t := styles.CurrentTheme()
73
80
74− titleStyle := t.S().Text.Padding(0, 1).Width(c.width)
81+ itemStyle := t.S().Base.Padding(0, 1).Width(c.width)
82+ innerWidth := c.width - 2 // Account for padding
83+
84+ if c.shortcut != "" {
85+ innerWidth -= lipgloss.Width(c.shortcut)
86+ }
87+
88+ titleStyle := t.S().Text.Width(innerWidth)
75 titleMatchStyle := t.S().Text.Underline(true)
89 titleMatchStyle := t.S().Text.Underline(true)
76 if c.bgColor != nil {
90 if c.bgColor != nil {
77 titleStyle = titleStyle.Background(c.bgColor)
91 titleStyle = titleStyle.Background(c.bgColor)
@@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View {
79 }
93 }
80
94
81 if c.focus {
95 if c.focus {
82− titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
96+ titleStyle = t.S().TextSelected.Width(innerWidth)
83 titleMatchStyle = t.S().TextSelected.Underline(true)
97 titleMatchStyle = t.S().TextSelected.Underline(true)
98+ itemStyle = itemStyle.Background(t.Primary)
84 }
99 }
85
100
86 var truncatedTitle string
101 var truncatedTitle string
87− var adjustedMatchIndexes []int
88
102
89− availableWidth := c.width - 2 // Account for padding
90− if len(c.matchIndexes) > 0 && len(c.text) > availableWidth {
103+ if len(c.matchIndexes) > 0 && len(c.text) > innerWidth {
91 // Smart truncation: ensure the last matching part is visible
104 // Smart truncation: ensure the last matching part is visible
92− truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes)
105+ truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes)
93 } else {
106 } else {
94 // No matches, use regular truncation
107 // No matches, use regular truncation
95− truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
108+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
96− adjustedMatchIndexes = c.matchIndexes
97 }
109 }
98
110
99 text := titleStyle.Render(truncatedTitle)
111 text := titleStyle.Render(truncatedTitle)
100− if len(adjustedMatchIndexes) > 0 {
112+ if len(c.matchIndexes) > 0 {
101 var ranges []lipgloss.Range
113 var ranges []lipgloss.Range
102− for _, rng := range matchedRanges(adjustedMatchIndexes) {
114+ for _, rng := range matchedRanges(c.matchIndexes) {
103 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
115 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
104 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
116 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
105 // so we need to adjust it here:
117 // so we need to adjust it here:
106− start, stop := bytePosToVisibleCharPos(text, rng)
118+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
107 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
119 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
108 }
120 }
109 text = lipgloss.StyleRanges(text, ranges...)
121 text = lipgloss.StyleRanges(text, ranges...)
110 }
122 }
123+ parts := []string{text}
124+ if c.shortcut != "" {
125+ // Add the shortcut at the end
126+ shortcutStyle := t.S().Muted
127+ if c.focus {
128+ shortcutStyle = t.S().TextSelected
129+ }
130+ parts = append(parts, shortcutStyle.Render(c.shortcut))
131+ }
132+ item := itemStyle.Render(
133+ lipgloss.JoinHorizontal(
134+ lipgloss.Left,
135+ parts...,
136+ ),
137+ )
111− return tea.NewView(text)
138+ return tea.NewView(item)
112}
139}
113
140
114// Blur implements CommandItem.
141// Blur implements CommandItem.
@@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
141
168
142func (c *completionItemCmp) MatchIndexes(indexes []int) {
169func (c *completionItemCmp) MatchIndexes(indexes []int) {
143 c.matchIndexes = indexes
170 c.matchIndexes = indexes
144− for i := range c.matchIndexes {
145− c.matchIndexes[i] += 1 // Adjust for the padding we add in View
146− }
147}
171}
148
172
149func (c *completionItemCmp) FilterValue() string {
173func (c *completionItemCmp) FilterValue() string {
@@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any {
155}
179}
156
180
157// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
181// smartTruncate implements fzf-style truncation that ensures the last matching part is visible
158−func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) {
182+func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string {
159 if width <= 0 {
183 if width <= 0 {
160− return "", []int{}
184+ return ""
161 }
185 }
162
186
163 textLen := ansi.StringWidth(text)
187 textLen := ansi.StringWidth(text)
164 if textLen <= width {
188 if textLen <= width {
165− return text, matchIndexes
189+ return text
166 }
190 }
167
191
168 if len(matchIndexes) == 0 {
192 if len(matchIndexes) == 0 {
169− return ansi.Truncate(text, width, "…"), []int{}
193+ return ansi.Truncate(text, width, "…")
170 }
194 }
171
195
172 // Find the last match position
196 // Find the last match position
@@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
187
211
188 // If the last match is within the available width, truncate from the end
212 // If the last match is within the available width, truncate from the end
189 if lastMatchVisualPos < availableWidth {
213 if lastMatchVisualPos < availableWidth {
190− return ansi.Truncate(text, width, "…"), matchIndexes
214+ return ansi.Truncate(text, width, "…")
191 }
215 }
192
216
193 // Calculate the start position to ensure the last match is visible
217 // Calculate the start position to ensure the last match is visible
@@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
209 // Truncate to fit width with ellipsis
233 // Truncate to fit width with ellipsis
210 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
234 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
211 truncatedText = "…" + truncatedText
235 truncatedText = "…" + truncatedText
212−
213− // Adjust match indexes for the new truncated string
214− adjustedIndexes := []int{}
215− for _, idx := range matchIndexes {
216− if idx >= startBytePos {
217− newIdx := idx - startBytePos + 1 //
218− // Check if this match is still within the truncated string
219− if newIdx < len(truncatedText) {
220− adjustedIndexes = append(adjustedIndexes, newIdx)
221− }
222− }
223− }
224−
225− return truncatedText, adjustedIndexes
236+ return truncatedText
226}
237}
227
238
228func matchedRanges(in []int) [][2]int {
239func matchedRanges(in []int) [][2]int {
internal/tui/components/core/status/keys.go+6
@@ -8,6 +8,7 @@ import (
8type KeyMap struct {
8type KeyMap struct {
9 Tab,
9 Tab,
10 Commands,
10 Commands,
11+ Sessions,
11 Help key.Binding
12 Help key.Binding
12}
13}
13
14
@@ -21,6 +22,10 @@ func DefaultKeyMap(tabHelp string) KeyMap {
21 key.WithKeys("ctrl+p"),
22 key.WithKeys("ctrl+p"),
22 key.WithHelp("ctrl+p", "commands"),
23 key.WithHelp("ctrl+p", "commands"),
23 ),
24 ),
25+ Sessions: key.NewBinding(
26+ key.WithKeys("ctrl+s"),
27+ key.WithHelp("ctrl+s", "sessions"),
28+ ),
24 Help: key.NewBinding(
29 Help: key.NewBinding(
25 key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
30 key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
26 key.WithHelp("ctrl+?", "more"),
31 key.WithHelp("ctrl+?", "more"),
@@ -44,6 +49,7 @@ func (k KeyMap) ShortHelp() []key.Binding {
44 return []key.Binding{
49 return []key.Binding{
45 k.Tab,
50 k.Tab,
46 k.Commands,
51 k.Commands,
52+ k.Sessions,
47 k.Help,
53 k.Help,
48 }
54 }
49}
55}
internal/tui/components/dialogs/commands/commands.go+11−3
@@ -16,7 +16,7 @@ import (
16)
16)
17
17
18const (
18const (
19− commandsDialogID dialogs.DialogID = "commands"
19+ CommandsDialogID dialogs.DialogID = "commands"
20
20
21 defaultWidth int = 70
21 defaultWidth int = 70
22)
22)
@@ -31,6 +31,7 @@ type Command struct {
31 ID string
31 ID string
32 Title string
32 Title string
33 Description string
33 Description string
34+ Shortcut string // Optional shortcut for the command
34 Handler func(cmd Command) tea.Cmd
35 Handler func(cmd Command) tea.Cmd
35}
36}
36
37
@@ -126,6 +127,8 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126 } else {
127 } else {
127 return c, c.SetCommandType(SystemCommands)
128 return c, c.SetCommandType(SystemCommands)
128 }
129 }
130+ case key.Matches(msg, c.keyMap.Close):
131+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
129 default:
132 default:
130 u, cmd := c.commandList.Update(msg)
133 u, cmd := c.commandList.Update(msg)
131 c.commandList = u.(list.ListModel)
134 c.commandList = u.(list.ListModel)
@@ -181,7 +184,11 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
181
184
182 commandItems := []util.Model{}
185 commandItems := []util.Model{}
183 for _, cmd := range commands {
186 for _, cmd := range commands {
187+ opts := []completions.CompletionOption{}
188+ if cmd.Shortcut != "" {
189+ opts = append(opts, completions.WithShortcut(cmd.Shortcut))
190+ }
184− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
191+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
185 }
192 }
186 return c.commandList.SetItems(commandItems)
193 return c.commandList.SetItems(commandItems)
187}
194}
@@ -250,6 +257,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
250 ID: "switch_session",
257 ID: "switch_session",
251 Title: "Switch Session",
258 Title: "Switch Session",
252 Description: "Switch to a different session",
259 Description: "Switch to a different session",
260+ Shortcut: "ctrl+s",
253 Handler: func(cmd Command) tea.Cmd {
261 Handler: func(cmd Command) tea.Cmd {
254 return func() tea.Msg {
262 return func() tea.Msg {
255 return SwitchSessionsMsg{}
263 return SwitchSessionsMsg{}
@@ -270,5 +278,5 @@ func (c *commandDialogCmp) defaultCommands() []Command {
270}
278}
271
279
272func (c *commandDialogCmp) ID() dialogs.DialogID {
280func (c *commandDialogCmp) ID() dialogs.DialogID {
273− return commandsDialogID
281+ return CommandsDialogID
274}
282}
internal/tui/components/dialogs/commands/keys.go+10−8
@@ -6,10 +6,11 @@ import (
6)
6)
7
7
8type CommandsDialogKeyMap struct {
8type CommandsDialogKeyMap struct {
9− Select key.Binding
9+ Select,
10− Next key.Binding
10+ Next,
11− Previous key.Binding
11+ Previous,
12+ Tab,
12− Tab key.Binding
13+ Close key.Binding
13}
14}
14
15
15func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
16func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
@@ -30,6 +31,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
30 key.WithKeys("tab"),
31 key.WithKeys("tab"),
31 key.WithHelp("tab", "switch selection"),
32 key.WithHelp("tab", "switch selection"),
32 ),
33 ),
34+ Close: key.NewBinding(
35+ key.WithKeys("esc"),
36+ key.WithHelp("esc", "cancel"),
37+ ),
33 }
38 }
34}
39}
35
40
@@ -53,10 +58,7 @@ func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
53 key.WithHelp("↑↓", "choose"),
58 key.WithHelp("↑↓", "choose"),
54 ),
59 ),
55 k.Select,
60 k.Select,
56− key.NewBinding(
61+ k.Close,
57− key.WithKeys("esc"),
58− key.WithHelp("esc", "cancel"),
59− ),
60 }
62 }
61}
63}
62
64
internal/tui/components/dialogs/dialogs.go+8−5
@@ -3,7 +3,6 @@ package dialogs
3import (
3import (
4 "slices"
4 "slices"
5
5
6− "github.com/charmbracelet/bubbles/v2/key"
7 tea "github.com/charmbracelet/bubbletea/v2"
6 tea "github.com/charmbracelet/bubbletea/v2"
8 "github.com/charmbracelet/lipgloss/v2"
7 "github.com/charmbracelet/lipgloss/v2"
9 "github.com/opencode-ai/opencode/internal/tui/util"
8 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,6 +38,7 @@ type DialogCmp interface {
39 HasDialogs() bool
38 HasDialogs() bool
40 GetLayers() []*lipgloss.Layer
39 GetLayers() []*lipgloss.Layer
41 ActiveView() *tea.View
40 ActiveView() *tea.View
41+ ActiveDialogId() DialogID
42}
42}
43
43
44type dialogCmp struct {
44type dialogCmp struct {
@@ -88,10 +88,6 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
88 return d, closeable.Close()
88 return d, closeable.Close()
89 }
89 }
90 return d, nil
90 return d, nil
91− case tea.KeyPressMsg:
92− if key.Matches(msg, d.keyMap.Close) {
93− return d, util.CmdHandler(CloseDialogMsg{})
94− }
95 }
91 }
96 if d.HasDialogs() {
92 if d.HasDialogs() {
97 lastIndex := len(d.dialogs) - 1
93 lastIndex := len(d.dialogs) - 1
@@ -144,6 +140,13 @@ func (d dialogCmp) ActiveView() *tea.View {
144 return &view
140 return &view
145}
141}
146
142
143+func (d dialogCmp) ActiveDialogId() DialogID {
144+ if len(d.dialogs) == 0 {
145+ return ""
146+ }
147+ return d.dialogs[len(d.dialogs)-1].ID()
148+}
149+
147func (d dialogCmp) GetLayers() []*lipgloss.Layer {
150func (d dialogCmp) GetLayers() []*lipgloss.Layer {
148 layers := []*lipgloss.Layer{}
151 layers := []*lipgloss.Layer{}
149 for _, dialog := range d.Dialogs() {
152 for _, dialog := range d.Dialogs() {
internal/tui/components/dialogs/models/keys.go+9−7
@@ -6,9 +6,10 @@ import (
6)
6)
7
7
8type KeyMap struct {
8type KeyMap struct {
9− Select key.Binding
9+ Select,
10− Next key.Binding
10+ Next,
11− Previous key.Binding
11+ Previous,
12+ Close key.Binding
12}
13}
13
14
14func DefaultKeyMap() KeyMap {
15func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
25 key.WithKeys("up", "ctrl+p"),
26 key.WithKeys("up", "ctrl+p"),
26 key.WithHelp("↑", "previous item"),
27 key.WithHelp("↑", "previous item"),
27 ),
28 ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
28 }
33 }
29}
34}
30
35
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
48 key.WithHelp("↑↓", "choose"),
53 key.WithHelp("↑↓", "choose"),
49 ),
54 ),
50 k.Select,
55 k.Select,
51− key.NewBinding(
56+ k.Close,
52− key.WithKeys("esc"),
53− key.WithHelp("esc", "cancel"),
54− ),
55 }
57 }
56}
58}
internal/tui/components/dialogs/models/models.go+4−2
@@ -19,7 +19,7 @@ import (
19)
19)
20
20
21const (
21const (
22− ID dialogs.DialogID = "models"
22+ ModelsDialogID dialogs.DialogID = "models"
23
23
24 defaultWidth = 60
24 defaultWidth = 60
25)
25)
@@ -145,6 +145,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145 util.CmdHandler(dialogs.CloseDialogMsg{}),
145 util.CmdHandler(dialogs.CloseDialogMsg{}),
146 util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
146 util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
147 )
147 )
148+ case key.Matches(msg, m.keyMap.Close):
149+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
148 default:
150 default:
149 u, cmd := m.modelList.Update(msg)
151 u, cmd := m.modelList.Update(msg)
150 m.modelList = u.(list.ListModel)
152 m.modelList = u.(list.ListModel)
@@ -257,5 +259,5 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
257}
259}
258
260
259func (m *modelDialogCmp) ID() dialogs.DialogID {
261func (m *modelDialogCmp) ID() dialogs.DialogID {
260− return ID
262+ return ModelsDialogID
261}
263}
internal/tui/components/dialogs/quit/keys.go+10−5
@@ -7,11 +7,12 @@ import (
7
7
8// KeyMap defines the keyboard bindings for the quit dialog.
8// KeyMap defines the keyboard bindings for the quit dialog.
9type KeyMap struct {
9type KeyMap struct {
10− LeftRight key.Binding
10+ LeftRight,
11− EnterSpace key.Binding
11+ EnterSpace,
12− Yes key.Binding
12+ Yes,
13− No key.Binding
13+ No,
14+ Tab,
14− Tab key.Binding
15+ Close key.Binding
15}
16}
16
17
17func DefaultKeymap() KeyMap {
18func DefaultKeymap() KeyMap {
@@ -36,6 +37,10 @@ func DefaultKeymap() KeyMap {
36 key.WithKeys("tab"),
37 key.WithKeys("tab"),
37 key.WithHelp("tab", "switch options"),
38 key.WithHelp("tab", "switch options"),
38 ),
39 ),
40+ Close: key.NewBinding(
41+ key.WithKeys("esc"),
42+ key.WithHelp("esc", "cancel"),
43+ ),
39 }
44 }
40}
45}
41
46
internal/tui/components/dialogs/quit/quit.go+5−5
@@ -11,8 +11,8 @@ import (
11)
11)
12
12
13const (
13const (
14− question = "Are you sure you want to quit?"
14+ question = "Are you sure you want to quit?"
15− id dialogs.DialogID = "quit"
15+ QuitDialogID dialogs.DialogID = "quit"
16)
16)
17
17
18// QuitDialog represents a confirmation dialog for quitting the application.
18// QuitDialog represents a confirmation dialog for quitting the application.
@@ -49,7 +49,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
49 q.wHeight = msg.Height
49 q.wHeight = msg.Height
50 case tea.KeyPressMsg:
50 case tea.KeyPressMsg:
51 switch {
51 switch {
52− case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab):
52+ case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab):
53 q.selectedNo = !q.selectedNo
53 q.selectedNo = !q.selectedNo
54 return q, nil
54 return q, nil
55 case key.Matches(msg, q.keymap.EnterSpace):
55 case key.Matches(msg, q.keymap.EnterSpace):
@@ -59,7 +59,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
59 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
59 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
60 case key.Matches(msg, q.keymap.Yes):
60 case key.Matches(msg, q.keymap.Yes):
61 return q, tea.Quit
61 return q, tea.Quit
62− case key.Matches(msg, q.keymap.No):
62+ case key.Matches(msg, q.keymap.No, q.keymap.Close):
63 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
63 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
64 }
64 }
65 }
65 }
@@ -121,5 +121,5 @@ func (q *quitDialogCmp) Position() (int, int) {
121}
121}
122
122
123func (q *quitDialogCmp) ID() dialogs.DialogID {
123func (q *quitDialogCmp) ID() dialogs.DialogID {
124− return id
124+ return QuitDialogID
125}
125}
internal/tui/components/dialogs/sessions/keys.go+9−7
@@ -6,9 +6,10 @@ import (
6)
6)
7
7
8type KeyMap struct {
8type KeyMap struct {
9− Select key.Binding
9+ Select,
10− Next key.Binding
10+ Next,
11− Previous key.Binding
11+ Previous,
12+ Close key.Binding
12}
13}
13
14
14func DefaultKeyMap() KeyMap {
15func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
25 key.WithKeys("up", "ctrl+p"),
26 key.WithKeys("up", "ctrl+p"),
26 key.WithHelp("↑", "previous item"),
27 key.WithHelp("↑", "previous item"),
27 ),
28 ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
28 }
33 }
29}
34}
30
35
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
48 key.WithHelp("↑↓", "choose"),
53 key.WithHelp("↑↓", "choose"),
49 ),
54 ),
50 k.Select,
55 k.Select,
51− key.NewBinding(
56+ k.Close,
52− key.WithKeys("esc"),
53− key.WithHelp("esc", "cancel"),
54− ),
55 }
57 }
56}
58}
internal/tui/components/dialogs/sessions/sessions.go+4−2
@@ -15,7 +15,7 @@ import (
15 "github.com/opencode-ai/opencode/internal/tui/util"
15 "github.com/opencode-ai/opencode/internal/tui/util"
16)
16)
17
17
18−const id dialogs.DialogID = "sessions"
18+const SessionsDialogID dialogs.DialogID = "sessions"
19
19
20// SessionDialog interface for the session switching dialog
20// SessionDialog interface for the session switching dialog
21type SessionDialog interface {
21type SessionDialog interface {
@@ -113,6 +113,8 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113 ),
113 ),
114 )
114 )
115 }
115 }
116+ case key.Matches(msg, s.keyMap.Close):
117+ return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116 default:
118 default:
117 u, cmd := s.sessionsList.Update(msg)
119 u, cmd := s.sessionsList.Update(msg)
118 s.sessionsList = u.(list.ListModel)
120 s.sessionsList = u.(list.ListModel)
@@ -174,5 +176,5 @@ func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
174
176
175// ID implements SessionDialog.
177// ID implements SessionDialog.
176func (s *sessionDialogCmp) ID() dialogs.DialogID {
178func (s *sessionDialogCmp) ID() dialogs.DialogID {
177− return id
179+ return SessionsDialogID
178}
180}
internal/tui/keys.go+5
@@ -10,6 +10,7 @@ type KeyMap struct {
10 Quit key.Binding
10 Quit key.Binding
11 Help key.Binding
11 Help key.Binding
12 Commands key.Binding
12 Commands key.Binding
13+ Sessions key.Binding
13 FilePicker key.Binding
14 FilePicker key.Binding
14}
15}
15
16
@@ -32,6 +33,10 @@ func DefaultKeyMap() KeyMap {
32 key.WithKeys("ctrl+p"),
33 key.WithKeys("ctrl+p"),
33 key.WithHelp("ctrl+p", "commands"),
34 key.WithHelp("ctrl+p", "commands"),
34 ),
35 ),
36+ Sessions: key.NewBinding(
37+ key.WithKeys("ctrl+s"),
38+ key.WithHelp("ctrl+s", "sessions"),
39+ ),
35 FilePicker: key.NewBinding(
40 FilePicker: key.NewBinding(
36 key.WithKeys("ctrl+f"),
41 key.WithKeys("ctrl+f"),
37 key.WithHelp("ctrl+f", "select files to upload"),
42 key.WithHelp("ctrl+f", "select files to upload"),
internal/tui/tui.go+27
@@ -189,14 +189,41 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd {
189 return cmd
189 return cmd
190 // dialogs
190 // dialogs
191 case key.Matches(msg, a.keyMap.Quit):
191 case key.Matches(msg, a.keyMap.Quit):
192+ if a.dialog.ActiveDialogId() == quit.QuitDialogID {
193+ // if the quit dialog is already open, close the app
194+ return tea.Quit
195+ }
192 return util.CmdHandler(dialogs.OpenDialogMsg{
196 return util.CmdHandler(dialogs.OpenDialogMsg{
193 Model: quit.NewQuitDialog(),
197 Model: quit.NewQuitDialog(),
194 })
198 })
195
199
196 case key.Matches(msg, a.keyMap.Commands):
200 case key.Matches(msg, a.keyMap.Commands):
201+ if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
202+ // If the commands dialog is already open, close it
203+ return util.CmdHandler(dialogs.CloseDialogMsg{})
204+ }
197 return util.CmdHandler(dialogs.OpenDialogMsg{
205 return util.CmdHandler(dialogs.OpenDialogMsg{
198 Model: commands.NewCommandDialog(),
206 Model: commands.NewCommandDialog(),
199 })
207 })
208+ case key.Matches(msg, a.keyMap.Sessions):
209+ if a.dialog.ActiveDialogId() == sessions.SessionsDialogID {
210+ // If the sessions dialog is already open, close it
211+ return util.CmdHandler(dialogs.CloseDialogMsg{})
212+ }
213+ var cmds []tea.Cmd
214+ if a.dialog.ActiveDialogId() == commands.CommandsDialogID {
215+ // If the commands dialog is open, close it first
216+ cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{}))
217+ }
218+ cmds = append(cmds,
219+ func() tea.Msg {
220+ allSessions, _ := a.app.Sessions.List(context.Background())
221+ return dialogs.OpenDialogMsg{
222+ Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID),
223+ }
224+ },
225+ )
226+ return tea.Sequence(cmds...)
200 // Page navigation
227 // Page navigation
201 case key.Matches(msg, a.keyMap.Logs):
228 case key.Matches(msg, a.keyMap.Logs):
202 return a.moveToPage(page.LogsPage)
229 return a.moveToPage(page.LogsPage)