on main help sign in
add shortcuts
commit3311aed0bdb05ee9ebdd533a08791aa7b390231a authorKujtim Hoxha <kujtimii.h@gmail.com> dateWed 4 Jun 2025, 21:43 (1 year ago) parente359209
13 files changed+157−82
internal/tui/components/completions/item.go+49−38 internal/tui/components/core/status/keys.go+6 internal/tui/components/dialogs/commands/commands.go+11−3 internal/tui/components/dialogs/commands/keys.go+10−8 internal/tui/components/dialogs/dialogs.go+8−5 internal/tui/components/dialogs/models/keys.go+9−7 internal/tui/components/dialogs/models/models.go+4−2 internal/tui/components/dialogs/quit/keys.go+10−5 internal/tui/components/dialogs/quit/quit.go+5−5 internal/tui/components/dialogs/sessions/keys.go+9−7 internal/tui/components/dialogs/sessions/sessions.go+4−2 internal/tui/keys.go+5 internal/tui/tui.go+27
internal/tui/components/completions/item.go+49−38
@@ -29,23 +29,30 @@ type completionItemCmp struct {
2929 focus bool
3030 matchIndexes []int
3131 bgColor color.Color
32+ shortcut string
3233}
3334
34−type completionOptions func(*completionItemCmp)
35+type CompletionOption func(*completionItemCmp)
3536
36−func WithBackgroundColor(c color.Color) completionOptions {
37+func WithBackgroundColor(c color.Color) CompletionOption {
3738 return func(cmp *completionItemCmp) {
3839 cmp.bgColor = c
3940 }
4041}
4142
42−func WithMatchIndexes(indexes ...int) completionOptions {
43+func WithMatchIndexes(indexes ...int) CompletionOption {
4344 return func(cmp *completionItemCmp) {
4445 cmp.matchIndexes = indexes
4546 }
4647}
4748
48−func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem {
49+func WithShortcut(shortcut string) CompletionOption {
50+ return func(cmp *completionItemCmp) {
51+ cmp.shortcut = shortcut
52+ }
53+}
54+
55+func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem {
4956 c := &completionItemCmp{
5057 text: text,
5158 value: value,
@@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
7178func (c *completionItemCmp) View() tea.View {
7279 t := styles.CurrentTheme()
7380
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)
7589 titleMatchStyle := t.S().Text.Underline(true)
7690 if c.bgColor != nil {
7791 titleStyle = titleStyle.Background(c.bgColor)
@@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View {
7993 }
8094
8195 if c.focus {
82− titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width)
96+ titleStyle = t.S().TextSelected.Width(innerWidth)
8397 titleMatchStyle = t.S().TextSelected.Underline(true)
98+ itemStyle = itemStyle.Background(t.Primary)
8499 }
85100
86101 var truncatedTitle string
87− var adjustedMatchIndexes []int
88102
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 {
91104 // 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)
93106 } else {
94107 // No matches, use regular truncation
95− truncatedTitle = ansi.Truncate(c.text, availableWidth, "…")
96− adjustedMatchIndexes = c.matchIndexes
108+ truncatedTitle = ansi.Truncate(c.text, innerWidth, "…")
97109 }
98110
99111 text := titleStyle.Render(truncatedTitle)
100− if len(adjustedMatchIndexes) > 0 {
112+ if len(c.matchIndexes) > 0 {
101113 var ranges []lipgloss.Range
102− for _, rng := range matchedRanges(adjustedMatchIndexes) {
114+ for _, rng := range matchedRanges(c.matchIndexes) {
103115 // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes.
104116 // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions.
105117 // so we need to adjust it here:
106− start, stop := bytePosToVisibleCharPos(text, rng)
118+ start, stop := bytePosToVisibleCharPos(truncatedTitle, rng)
107119 ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle))
108120 }
109121 text = lipgloss.StyleRanges(text, ranges...)
110122 }
111− return tea.NewView(text)
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+ )
138+ return tea.NewView(item)
112139}
113140
114141// Blur implements CommandItem.
@@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd {
141168
142169func (c *completionItemCmp) MatchIndexes(indexes []int) {
143170 c.matchIndexes = indexes
144− for i := range c.matchIndexes {
145− c.matchIndexes[i] += 1 // Adjust for the padding we add in View
146− }
147171}
148172
149173func (c *completionItemCmp) FilterValue() string {
@@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any {
155179}
156180
157181// 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 {
159183 if width <= 0 {
160− return "", []int{}
184+ return ""
161185 }
162186
163187 textLen := ansi.StringWidth(text)
164188 if textLen <= width {
165− return text, matchIndexes
189+ return text
166190 }
167191
168192 if len(matchIndexes) == 0 {
169− return ansi.Truncate(text, width, "…"), []int{}
193+ return ansi.Truncate(text, width, "…")
170194 }
171195
172196 // Find the last match position
@@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
187211
188212 // If the last match is within the available width, truncate from the end
189213 if lastMatchVisualPos < availableWidth {
190− return ansi.Truncate(text, width, "…"), matchIndexes
214+ return ansi.Truncate(text, width, "…")
191215 }
192216
193217 // Calculate the start position to ensure the last match is visible
@@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [
209233 // Truncate to fit width with ellipsis
210234 truncatedText = ansi.Truncate(truncatedText, availableWidth, "")
211235 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
226237}
227238
228239func matchedRanges(in []int) [][2]int {
internal/tui/components/core/status/keys.go+6
@@ -8,6 +8,7 @@ import (
88type KeyMap struct {
99 Tab,
1010 Commands,
11+ Sessions,
1112 Help key.Binding
1213}
1314
@@ -21,6 +22,10 @@ func DefaultKeyMap(tabHelp string) KeyMap {
2122 key.WithKeys("ctrl+p"),
2223 key.WithHelp("ctrl+p", "commands"),
2324 ),
25+ Sessions: key.NewBinding(
26+ key.WithKeys("ctrl+s"),
27+ key.WithHelp("ctrl+s", "sessions"),
28+ ),
2429 Help: key.NewBinding(
2530 key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"),
2631 key.WithHelp("ctrl+?", "more"),
@@ -44,6 +49,7 @@ func (k KeyMap) ShortHelp() []key.Binding {
4449 return []key.Binding{
4550 k.Tab,
4651 k.Commands,
52+ k.Sessions,
4753 k.Help,
4854 }
4955}
internal/tui/components/dialogs/commands/commands.go+11−3
@@ -16,7 +16,7 @@ import (
1616)
1717
1818const (
19− commandsDialogID dialogs.DialogID = "commands"
19+ CommandsDialogID dialogs.DialogID = "commands"
2020
2121 defaultWidth int = 70
2222)
@@ -31,6 +31,7 @@ type Command struct {
3131 ID string
3232 Title string
3333 Description string
34+ Shortcut string // Optional shortcut for the command
3435 Handler func(cmd Command) tea.Cmd
3536}
3637
@@ -126,6 +127,8 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126127 } else {
127128 return c, c.SetCommandType(SystemCommands)
128129 }
130+ case key.Matches(msg, c.keyMap.Close):
131+ return c, util.CmdHandler(dialogs.CloseDialogMsg{})
129132 default:
130133 u, cmd := c.commandList.Update(msg)
131134 c.commandList = u.(list.ListModel)
@@ -181,7 +184,11 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd {
181184
182185 commandItems := []util.Model{}
183186 for _, cmd := range commands {
184− commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd))
187+ opts := []completions.CompletionOption{}
188+ if cmd.Shortcut != "" {
189+ opts = append(opts, completions.WithShortcut(cmd.Shortcut))
190+ }
191+ commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...))
185192 }
186193 return c.commandList.SetItems(commandItems)
187194}
@@ -250,6 +257,7 @@ func (c *commandDialogCmp) defaultCommands() []Command {
250257 ID: "switch_session",
251258 Title: "Switch Session",
252259 Description: "Switch to a different session",
260+ Shortcut: "ctrl+s",
253261 Handler: func(cmd Command) tea.Cmd {
254262 return func() tea.Msg {
255263 return SwitchSessionsMsg{}
@@ -270,5 +278,5 @@ func (c *commandDialogCmp) defaultCommands() []Command {
270278}
271279
272280func (c *commandDialogCmp) ID() dialogs.DialogID {
273− return commandsDialogID
281+ return CommandsDialogID
274282}
internal/tui/components/dialogs/commands/keys.go+10−8
@@ -6,10 +6,11 @@ import (
66)
77
88type CommandsDialogKeyMap struct {
9− Select key.Binding
10− Next key.Binding
11− Previous key.Binding
12− Tab key.Binding
9+ Select,
10+ Next,
11+ Previous,
12+ Tab,
13+ Close key.Binding
1314}
1415
1516func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
@@ -30,6 +31,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap {
3031 key.WithKeys("tab"),
3132 key.WithHelp("tab", "switch selection"),
3233 ),
34+ Close: key.NewBinding(
35+ key.WithKeys("esc"),
36+ key.WithHelp("esc", "cancel"),
37+ ),
3338 }
3439}
3540
@@ -53,10 +58,7 @@ func (k CommandsDialogKeyMap) ShortHelp() []key.Binding {
5358 key.WithHelp("↑↓", "choose"),
5459 ),
5560 k.Select,
56− key.NewBinding(
57− key.WithKeys("esc"),
58− key.WithHelp("esc", "cancel"),
59− ),
61+ k.Close,
6062 }
6163}
6264
internal/tui/components/dialogs/dialogs.go+8−5
@@ -3,7 +3,6 @@ package dialogs
33import (
44 "slices"
55
6− "github.com/charmbracelet/bubbles/v2/key"
76 tea "github.com/charmbracelet/bubbletea/v2"
87 "github.com/charmbracelet/lipgloss/v2"
98 "github.com/opencode-ai/opencode/internal/tui/util"
@@ -39,6 +38,7 @@ type DialogCmp interface {
3938 HasDialogs() bool
4039 GetLayers() []*lipgloss.Layer
4140 ActiveView() *tea.View
41+ ActiveDialogId() DialogID
4242}
4343
4444type dialogCmp struct {
@@ -88,10 +88,6 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
8888 return d, closeable.Close()
8989 }
9090 return d, nil
91− case tea.KeyPressMsg:
92− if key.Matches(msg, d.keyMap.Close) {
93− return d, util.CmdHandler(CloseDialogMsg{})
94− }
9591 }
9692 if d.HasDialogs() {
9793 lastIndex := len(d.dialogs) - 1
@@ -144,6 +140,13 @@ func (d dialogCmp) ActiveView() *tea.View {
144140 return &view
145141}
146142
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+
147150func (d dialogCmp) GetLayers() []*lipgloss.Layer {
148151 layers := []*lipgloss.Layer{}
149152 for _, dialog := range d.Dialogs() {
internal/tui/components/dialogs/models/keys.go+9−7
@@ -6,9 +6,10 @@ import (
66)
77
88type KeyMap struct {
9− Select key.Binding
10− Next key.Binding
11− Previous key.Binding
9+ Select,
10+ Next,
11+ Previous,
12+ Close key.Binding
1213}
1314
1415func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
2526 key.WithKeys("up", "ctrl+p"),
2627 key.WithHelp("↑", "previous item"),
2728 ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
2833 }
2934}
3035
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
4853 key.WithHelp("↑↓", "choose"),
4954 ),
5055 k.Select,
51− key.NewBinding(
52− key.WithKeys("esc"),
53− key.WithHelp("esc", "cancel"),
54− ),
56+ k.Close,
5557 }
5658}
internal/tui/components/dialogs/models/models.go+4−2
@@ -19,7 +19,7 @@ import (
1919)
2020
2121const (
22− ID dialogs.DialogID = "models"
22+ ModelsDialogID dialogs.DialogID = "models"
2323
2424 defaultWidth = 60
2525)
@@ -145,6 +145,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
145145 util.CmdHandler(dialogs.CloseDialogMsg{}),
146146 util.CmdHandler(ModelSelectedMsg{Model: selectedItem}),
147147 )
148+ case key.Matches(msg, m.keyMap.Close):
149+ return m, util.CmdHandler(dialogs.CloseDialogMsg{})
148150 default:
149151 u, cmd := m.modelList.Update(msg)
150152 m.modelList = u.(list.ListModel)
@@ -257,5 +259,5 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
257259}
258260
259261func (m *modelDialogCmp) ID() dialogs.DialogID {
260− return ID
262+ return ModelsDialogID
261263}
internal/tui/components/dialogs/quit/keys.go+10−5
@@ -7,11 +7,12 @@ import (
77
88// KeyMap defines the keyboard bindings for the quit dialog.
99type KeyMap struct {
10− LeftRight key.Binding
11− EnterSpace key.Binding
12− Yes key.Binding
13− No key.Binding
14− Tab key.Binding
10+ LeftRight,
11+ EnterSpace,
12+ Yes,
13+ No,
14+ Tab,
15+ Close key.Binding
1516}
1617
1718func DefaultKeymap() KeyMap {
@@ -36,6 +37,10 @@ func DefaultKeymap() KeyMap {
3637 key.WithKeys("tab"),
3738 key.WithHelp("tab", "switch options"),
3839 ),
40+ Close: key.NewBinding(
41+ key.WithKeys("esc"),
42+ key.WithHelp("esc", "cancel"),
43+ ),
3944 }
4045}
4146
internal/tui/components/dialogs/quit/quit.go+5−5
@@ -11,8 +11,8 @@ import (
1111)
1212
1313const (
14− question = "Are you sure you want to quit?"
15− id dialogs.DialogID = "quit"
14+ question = "Are you sure you want to quit?"
15+ QuitDialogID dialogs.DialogID = "quit"
1616)
1717
1818// QuitDialog represents a confirmation dialog for quitting the application.
@@ -49,7 +49,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4949 q.wHeight = msg.Height
5050 case tea.KeyPressMsg:
5151 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):
5353 q.selectedNo = !q.selectedNo
5454 return q, nil
5555 case key.Matches(msg, q.keymap.EnterSpace):
@@ -59,7 +59,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5959 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
6060 case key.Matches(msg, q.keymap.Yes):
6161 return q, tea.Quit
62− case key.Matches(msg, q.keymap.No):
62+ case key.Matches(msg, q.keymap.No, q.keymap.Close):
6363 return q, util.CmdHandler(dialogs.CloseDialogMsg{})
6464 }
6565 }
@@ -121,5 +121,5 @@ func (q *quitDialogCmp) Position() (int, int) {
121121}
122122
123123func (q *quitDialogCmp) ID() dialogs.DialogID {
124− return id
124+ return QuitDialogID
125125}
internal/tui/components/dialogs/sessions/keys.go+9−7
@@ -6,9 +6,10 @@ import (
66)
77
88type KeyMap struct {
9− Select key.Binding
10− Next key.Binding
11− Previous key.Binding
9+ Select,
10+ Next,
11+ Previous,
12+ Close key.Binding
1213}
1314
1415func DefaultKeyMap() KeyMap {
@@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap {
2526 key.WithKeys("up", "ctrl+p"),
2627 key.WithHelp("↑", "previous item"),
2728 ),
29+ Close: key.NewBinding(
30+ key.WithKeys("esc"),
31+ key.WithHelp("esc", "cancel"),
32+ ),
2833 }
2934}
3035
@@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding {
4853 key.WithHelp("↑↓", "choose"),
4954 ),
5055 k.Select,
51− key.NewBinding(
52− key.WithKeys("esc"),
53− key.WithHelp("esc", "cancel"),
54− ),
56+ k.Close,
5557 }
5658}
internal/tui/components/dialogs/sessions/sessions.go+4−2
@@ -15,7 +15,7 @@ import (
1515 "github.com/opencode-ai/opencode/internal/tui/util"
1616)
1717
18−const id dialogs.DialogID = "sessions"
18+const SessionsDialogID dialogs.DialogID = "sessions"
1919
2020// SessionDialog interface for the session switching dialog
2121type SessionDialog interface {
@@ -113,6 +113,8 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
113113 ),
114114 )
115115 }
116+ case key.Matches(msg, s.keyMap.Close):
117+ return s, util.CmdHandler(dialogs.CloseDialogMsg{})
116118 default:
117119 u, cmd := s.sessionsList.Update(msg)
118120 s.sessionsList = u.(list.ListModel)
@@ -174,5 +176,5 @@ func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor {
174176
175177// ID implements SessionDialog.
176178func (s *sessionDialogCmp) ID() dialogs.DialogID {
177− return id
179+ return SessionsDialogID
178180}
internal/tui/keys.go+5
@@ -10,6 +10,7 @@ type KeyMap struct {
1010 Quit key.Binding
1111 Help key.Binding
1212 Commands key.Binding
13+ Sessions key.Binding
1314 FilePicker key.Binding
1415}
1516
@@ -32,6 +33,10 @@ func DefaultKeyMap() KeyMap {
3233 key.WithKeys("ctrl+p"),
3334 key.WithHelp("ctrl+p", "commands"),
3435 ),
36+ Sessions: key.NewBinding(
37+ key.WithKeys("ctrl+s"),
38+ key.WithHelp("ctrl+s", "sessions"),
39+ ),
3540 FilePicker: key.NewBinding(
3641 key.WithKeys("ctrl+f"),
3742 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 {
189189 return cmd
190190 // dialogs
191191 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+ }
192196 return util.CmdHandler(dialogs.OpenDialogMsg{
193197 Model: quit.NewQuitDialog(),
194198 })
195199
196200 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+ }
197205 return util.CmdHandler(dialogs.OpenDialogMsg{
198206 Model: commands.NewCommandDialog(),
199207 })
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...)
200227 // Page navigation
201228 case key.Matches(msg, a.keyMap.Logs):
202229 return a.moveToPage(page.LogsPage)