Skip to content
This repository was archived by the owner on Sep 18, 2025. It is now read-only.

Commit 3311aed

Browse files
committed
add shortcuts
1 parent e359209 commit 3311aed

File tree

13 files changed

+157
-82
lines changed

13 files changed

+157
-82
lines changed

internal/tui/components/completions/item.go

Lines changed: 49 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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,44 +78,64 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) {
7178
func (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)
7892
titleMatchStyle = titleMatchStyle.Background(c.bgColor)
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

142169
func (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

149173
func (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

228239
func matchedRanges(in []int) [][2]int {

internal/tui/components/core/status/keys.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
type 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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
)
1717

1818
const (
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

272280
func (c *commandDialogCmp) ID() dialogs.DialogID {
273-
return commandsDialogID
281+
return CommandsDialogID
274282
}

internal/tui/components/dialogs/commands/keys.go

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@ import (
66
)
77

88
type 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

1516
func 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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package dialogs
33
import (
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

4444
type 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+
147150
func (d dialogCmp) GetLayers() []*lipgloss.Layer {
148151
layers := []*lipgloss.Layer{}
149152
for _, dialog := range d.Dialogs() {

internal/tui/components/dialogs/models/keys.go

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ import (
66
)
77

88
type 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

1415
func 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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import (
1919
)
2020

2121
const (
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

259261
func (m *modelDialogCmp) ID() dialogs.DialogID {
260-
return ID
262+
return ModelsDialogID
261263
}

0 commit comments

Comments
 (0)