From 3311aed0bdb05ee9ebdd533a08791aa7b390231a Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 4 Jun 2025 21:43:44 +0200 Subject: [PATCH] add shortcuts --- internal/tui/components/completions/item.go | 87 +++++++++++-------- internal/tui/components/core/status/keys.go | 6 ++ .../components/dialogs/commands/commands.go | 14 ++- .../tui/components/dialogs/commands/keys.go | 18 ++-- internal/tui/components/dialogs/dialogs.go | 13 +-- .../tui/components/dialogs/models/keys.go | 16 ++-- .../tui/components/dialogs/models/models.go | 6 +- internal/tui/components/dialogs/quit/keys.go | 15 ++-- internal/tui/components/dialogs/quit/quit.go | 10 +-- .../tui/components/dialogs/sessions/keys.go | 16 ++-- .../components/dialogs/sessions/sessions.go | 6 +- internal/tui/keys.go | 5 ++ internal/tui/tui.go | 27 ++++++ 13 files changed, 157 insertions(+), 82 deletions(-) diff --git a/internal/tui/components/completions/item.go b/internal/tui/components/completions/item.go index f7a2f628115fb4dee6957cf6b6968b7375b40e7f..324c07249bc784366e33f717d5a59d20b2eff7bf 100644 --- a/internal/tui/components/completions/item.go +++ b/internal/tui/components/completions/item.go @@ -29,23 +29,30 @@ type completionItemCmp struct { focus bool matchIndexes []int bgColor color.Color + shortcut string } -type completionOptions func(*completionItemCmp) +type CompletionOption func(*completionItemCmp) -func WithBackgroundColor(c color.Color) completionOptions { +func WithBackgroundColor(c color.Color) CompletionOption { return func(cmp *completionItemCmp) { cmp.bgColor = c } } -func WithMatchIndexes(indexes ...int) completionOptions { +func WithMatchIndexes(indexes ...int) CompletionOption { return func(cmp *completionItemCmp) { cmp.matchIndexes = indexes } } -func NewCompletionItem(text string, value any, opts ...completionOptions) CompletionItem { +func WithShortcut(shortcut string) CompletionOption { + return func(cmp *completionItemCmp) { + cmp.shortcut = shortcut + } +} + +func NewCompletionItem(text string, value any, opts ...CompletionOption) CompletionItem { c := &completionItemCmp{ text: text, value: value, @@ -71,7 +78,14 @@ func (c *completionItemCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { func (c *completionItemCmp) View() tea.View { t := styles.CurrentTheme() - titleStyle := t.S().Text.Padding(0, 1).Width(c.width) + itemStyle := t.S().Base.Padding(0, 1).Width(c.width) + innerWidth := c.width - 2 // Account for padding + + if c.shortcut != "" { + innerWidth -= lipgloss.Width(c.shortcut) + } + + titleStyle := t.S().Text.Width(innerWidth) titleMatchStyle := t.S().Text.Underline(true) if c.bgColor != nil { titleStyle = titleStyle.Background(c.bgColor) @@ -79,36 +93,49 @@ func (c *completionItemCmp) View() tea.View { } if c.focus { - titleStyle = t.S().TextSelected.Padding(0, 1).Width(c.width) + titleStyle = t.S().TextSelected.Width(innerWidth) titleMatchStyle = t.S().TextSelected.Underline(true) + itemStyle = itemStyle.Background(t.Primary) } var truncatedTitle string - var adjustedMatchIndexes []int - availableWidth := c.width - 2 // Account for padding - if len(c.matchIndexes) > 0 && len(c.text) > availableWidth { + if len(c.matchIndexes) > 0 && len(c.text) > innerWidth { // Smart truncation: ensure the last matching part is visible - truncatedTitle, adjustedMatchIndexes = c.smartTruncate(c.text, availableWidth, c.matchIndexes) + truncatedTitle = c.smartTruncate(c.text, innerWidth, c.matchIndexes) } else { // No matches, use regular truncation - truncatedTitle = ansi.Truncate(c.text, availableWidth, "…") - adjustedMatchIndexes = c.matchIndexes + truncatedTitle = ansi.Truncate(c.text, innerWidth, "…") } text := titleStyle.Render(truncatedTitle) - if len(adjustedMatchIndexes) > 0 { + if len(c.matchIndexes) > 0 { var ranges []lipgloss.Range - for _, rng := range matchedRanges(adjustedMatchIndexes) { + for _, rng := range matchedRanges(c.matchIndexes) { // ansi.Cut is grapheme and ansi sequence aware, we match against a ansi.Stripped string, but we might still have graphemes. // all that to say that rng is byte positions, but we need to pass it down to ansi.Cut as char positions. // so we need to adjust it here: - start, stop := bytePosToVisibleCharPos(text, rng) + start, stop := bytePosToVisibleCharPos(truncatedTitle, rng) ranges = append(ranges, lipgloss.NewRange(start, stop+1, titleMatchStyle)) } text = lipgloss.StyleRanges(text, ranges...) } - return tea.NewView(text) + parts := []string{text} + if c.shortcut != "" { + // Add the shortcut at the end + shortcutStyle := t.S().Muted + if c.focus { + shortcutStyle = t.S().TextSelected + } + parts = append(parts, shortcutStyle.Render(c.shortcut)) + } + item := itemStyle.Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + parts..., + ), + ) + return tea.NewView(item) } // Blur implements CommandItem. @@ -141,9 +168,6 @@ func (c *completionItemCmp) SetSize(width int, height int) tea.Cmd { func (c *completionItemCmp) MatchIndexes(indexes []int) { c.matchIndexes = indexes - for i := range c.matchIndexes { - c.matchIndexes[i] += 1 // Adjust for the padding we add in View - } } func (c *completionItemCmp) FilterValue() string { @@ -155,18 +179,18 @@ func (c *completionItemCmp) Value() any { } // smartTruncate implements fzf-style truncation that ensures the last matching part is visible -func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) (string, []int) { +func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes []int) string { if width <= 0 { - return "", []int{} + return "" } textLen := ansi.StringWidth(text) if textLen <= width { - return text, matchIndexes + return text } if len(matchIndexes) == 0 { - return ansi.Truncate(text, width, "…"), []int{} + return ansi.Truncate(text, width, "…") } // Find the last match position @@ -187,7 +211,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [ // If the last match is within the available width, truncate from the end if lastMatchVisualPos < availableWidth { - return ansi.Truncate(text, width, "…"), matchIndexes + return ansi.Truncate(text, width, "…") } // Calculate the start position to ensure the last match is visible @@ -209,20 +233,7 @@ func (c *completionItemCmp) smartTruncate(text string, width int, matchIndexes [ // Truncate to fit width with ellipsis truncatedText = ansi.Truncate(truncatedText, availableWidth, "") truncatedText = "…" + truncatedText - - // Adjust match indexes for the new truncated string - adjustedIndexes := []int{} - for _, idx := range matchIndexes { - if idx >= startBytePos { - newIdx := idx - startBytePos + 1 // - // Check if this match is still within the truncated string - if newIdx < len(truncatedText) { - adjustedIndexes = append(adjustedIndexes, newIdx) - } - } - } - - return truncatedText, adjustedIndexes + return truncatedText } func matchedRanges(in []int) [][2]int { diff --git a/internal/tui/components/core/status/keys.go b/internal/tui/components/core/status/keys.go index 245f4328bb2ee82fdb29777f1e5b482e3277e198..1c7a794ba96c1618cdef986c48ff36c492d1bacf 100644 --- a/internal/tui/components/core/status/keys.go +++ b/internal/tui/components/core/status/keys.go @@ -8,6 +8,7 @@ import ( type KeyMap struct { Tab, Commands, + Sessions, Help key.Binding } @@ -21,6 +22,10 @@ func DefaultKeyMap(tabHelp string) KeyMap { key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "commands"), ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), Help: key.NewBinding( key.WithKeys("ctrl+?", "ctrl+_", "ctrl+/"), key.WithHelp("ctrl+?", "more"), @@ -44,6 +49,7 @@ func (k KeyMap) ShortHelp() []key.Binding { return []key.Binding{ k.Tab, k.Commands, + k.Sessions, k.Help, } } diff --git a/internal/tui/components/dialogs/commands/commands.go b/internal/tui/components/dialogs/commands/commands.go index 127b11dcfd8ea8666a59db30346537633a299e9c..90ca45fa8a801bd8122fb0ebee9e855e46c08092 100644 --- a/internal/tui/components/dialogs/commands/commands.go +++ b/internal/tui/components/dialogs/commands/commands.go @@ -16,7 +16,7 @@ import ( ) const ( - commandsDialogID dialogs.DialogID = "commands" + CommandsDialogID dialogs.DialogID = "commands" defaultWidth int = 70 ) @@ -31,6 +31,7 @@ type Command struct { ID string Title string Description string + Shortcut string // Optional shortcut for the command Handler func(cmd Command) tea.Cmd } @@ -126,6 +127,8 @@ func (c *commandDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { return c, c.SetCommandType(SystemCommands) } + case key.Matches(msg, c.keyMap.Close): + return c, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := c.commandList.Update(msg) c.commandList = u.(list.ListModel) @@ -181,7 +184,11 @@ func (c *commandDialogCmp) SetCommandType(commandType int) tea.Cmd { commandItems := []util.Model{} for _, cmd := range commands { - commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd)) + opts := []completions.CompletionOption{} + if cmd.Shortcut != "" { + opts = append(opts, completions.WithShortcut(cmd.Shortcut)) + } + commandItems = append(commandItems, completions.NewCompletionItem(cmd.Title, cmd, opts...)) } return c.commandList.SetItems(commandItems) } @@ -250,6 +257,7 @@ func (c *commandDialogCmp) defaultCommands() []Command { ID: "switch_session", Title: "Switch Session", Description: "Switch to a different session", + Shortcut: "ctrl+s", Handler: func(cmd Command) tea.Cmd { return func() tea.Msg { return SwitchSessionsMsg{} @@ -270,5 +278,5 @@ func (c *commandDialogCmp) defaultCommands() []Command { } func (c *commandDialogCmp) ID() dialogs.DialogID { - return commandsDialogID + return CommandsDialogID } diff --git a/internal/tui/components/dialogs/commands/keys.go b/internal/tui/components/dialogs/commands/keys.go index 7bfe0fb69675c8e2c04edc78d59ac0dda05415cd..9b80591678b97af6c70aa2794e9e980d229fe441 100644 --- a/internal/tui/components/dialogs/commands/keys.go +++ b/internal/tui/components/dialogs/commands/keys.go @@ -6,10 +6,11 @@ import ( ) type CommandsDialogKeyMap struct { - Select key.Binding - Next key.Binding - Previous key.Binding - Tab key.Binding + Select, + Next, + Previous, + Tab, + Close key.Binding } func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { @@ -30,6 +31,10 @@ func DefaultCommandsDialogKeyMap() CommandsDialogKeyMap { key.WithKeys("tab"), key.WithHelp("tab", "switch selection"), ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -53,10 +58,7 @@ func (k CommandsDialogKeyMap) ShortHelp() []key.Binding { key.WithHelp("↑↓", "choose"), ), k.Select, - key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), + k.Close, } } diff --git a/internal/tui/components/dialogs/dialogs.go b/internal/tui/components/dialogs/dialogs.go index f5e5e285de96ed7b59e0f6600ef9eb78548c22cd..58a25ae446309ca3f33bfb1aafc407453fff61f6 100644 --- a/internal/tui/components/dialogs/dialogs.go +++ b/internal/tui/components/dialogs/dialogs.go @@ -3,7 +3,6 @@ package dialogs import ( "slices" - "github.com/charmbracelet/bubbles/v2/key" tea "github.com/charmbracelet/bubbletea/v2" "github.com/charmbracelet/lipgloss/v2" "github.com/opencode-ai/opencode/internal/tui/util" @@ -39,6 +38,7 @@ type DialogCmp interface { HasDialogs() bool GetLayers() []*lipgloss.Layer ActiveView() *tea.View + ActiveDialogId() DialogID } type dialogCmp struct { @@ -88,10 +88,6 @@ func (d dialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return d, closeable.Close() } return d, nil - case tea.KeyPressMsg: - if key.Matches(msg, d.keyMap.Close) { - return d, util.CmdHandler(CloseDialogMsg{}) - } } if d.HasDialogs() { lastIndex := len(d.dialogs) - 1 @@ -144,6 +140,13 @@ func (d dialogCmp) ActiveView() *tea.View { return &view } +func (d dialogCmp) ActiveDialogId() DialogID { + if len(d.dialogs) == 0 { + return "" + } + return d.dialogs[len(d.dialogs)-1].ID() +} + func (d dialogCmp) GetLayers() []*lipgloss.Layer { layers := []*lipgloss.Layer{} for _, dialog := range d.Dialogs() { diff --git a/internal/tui/components/dialogs/models/keys.go b/internal/tui/components/dialogs/models/keys.go index 50bec18f2f51fd695582d7cf5f799fffaee8d577..17d21193edaf6b6bfa1ec4f53a9e91b8fba28b80 100644 --- a/internal/tui/components/dialogs/models/keys.go +++ b/internal/tui/components/dialogs/models/keys.go @@ -6,9 +6,10 @@ import ( ) type KeyMap struct { - Select key.Binding - Next key.Binding - Previous key.Binding + Select, + Next, + Previous, + Close key.Binding } func DefaultKeyMap() KeyMap { @@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding { key.WithHelp("↑↓", "choose"), ), k.Select, - key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), + k.Close, } } diff --git a/internal/tui/components/dialogs/models/models.go b/internal/tui/components/dialogs/models/models.go index b2ee4e8bb6fd7631a03c90c46a7bbb2cab8b274c..8cb19998b87891c560971ff37d734b7858a59ee6 100644 --- a/internal/tui/components/dialogs/models/models.go +++ b/internal/tui/components/dialogs/models/models.go @@ -19,7 +19,7 @@ import ( ) const ( - ID dialogs.DialogID = "models" + ModelsDialogID dialogs.DialogID = "models" defaultWidth = 60 ) @@ -145,6 +145,8 @@ func (m *modelDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { util.CmdHandler(dialogs.CloseDialogMsg{}), util.CmdHandler(ModelSelectedMsg{Model: selectedItem}), ) + case key.Matches(msg, m.keyMap.Close): + return m, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := m.modelList.Update(msg) m.modelList = u.(list.ListModel) @@ -257,5 +259,5 @@ func (m *modelDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { } func (m *modelDialogCmp) ID() dialogs.DialogID { - return ID + return ModelsDialogID } diff --git a/internal/tui/components/dialogs/quit/keys.go b/internal/tui/components/dialogs/quit/keys.go index a2459af696d16ed497565b71775887d7f75f317d..426bcc6c38b03257e81088fd7a2c6534e4facb6e 100644 --- a/internal/tui/components/dialogs/quit/keys.go +++ b/internal/tui/components/dialogs/quit/keys.go @@ -7,11 +7,12 @@ import ( // KeyMap defines the keyboard bindings for the quit dialog. type KeyMap struct { - LeftRight key.Binding - EnterSpace key.Binding - Yes key.Binding - No key.Binding - Tab key.Binding + LeftRight, + EnterSpace, + Yes, + No, + Tab, + Close key.Binding } func DefaultKeymap() KeyMap { @@ -36,6 +37,10 @@ func DefaultKeymap() KeyMap { key.WithKeys("tab"), key.WithHelp("tab", "switch options"), ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } } diff --git a/internal/tui/components/dialogs/quit/quit.go b/internal/tui/components/dialogs/quit/quit.go index df0dbf8887bbbb8d512de4dc911448e695ff62d8..d370be34a2e5283deb37f7ea0a397d9817515671 100644 --- a/internal/tui/components/dialogs/quit/quit.go +++ b/internal/tui/components/dialogs/quit/quit.go @@ -11,8 +11,8 @@ import ( ) const ( - question = "Are you sure you want to quit?" - id dialogs.DialogID = "quit" + question = "Are you sure you want to quit?" + QuitDialogID dialogs.DialogID = "quit" ) // QuitDialog represents a confirmation dialog for quitting the application. @@ -49,7 +49,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { q.wHeight = msg.Height case tea.KeyPressMsg: switch { - case key.Matches(msg, q.keymap.LeftRight) || key.Matches(msg, q.keymap.Tab): + case key.Matches(msg, q.keymap.LeftRight, q.keymap.Tab): q.selectedNo = !q.selectedNo return q, nil case key.Matches(msg, q.keymap.EnterSpace): @@ -59,7 +59,7 @@ func (q *quitDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return q, util.CmdHandler(dialogs.CloseDialogMsg{}) case key.Matches(msg, q.keymap.Yes): return q, tea.Quit - case key.Matches(msg, q.keymap.No): + case key.Matches(msg, q.keymap.No, q.keymap.Close): return q, util.CmdHandler(dialogs.CloseDialogMsg{}) } } @@ -121,5 +121,5 @@ func (q *quitDialogCmp) Position() (int, int) { } func (q *quitDialogCmp) ID() dialogs.DialogID { - return id + return QuitDialogID } diff --git a/internal/tui/components/dialogs/sessions/keys.go b/internal/tui/components/dialogs/sessions/keys.go index 2ec423d865cbcaa330f87dc652b60556c4886f33..91cc069c18804e0bdde3557f9a24f54dceb9cdc8 100644 --- a/internal/tui/components/dialogs/sessions/keys.go +++ b/internal/tui/components/dialogs/sessions/keys.go @@ -6,9 +6,10 @@ import ( ) type KeyMap struct { - Select key.Binding - Next key.Binding - Previous key.Binding + Select, + Next, + Previous, + Close key.Binding } func DefaultKeyMap() KeyMap { @@ -25,6 +26,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("up", "ctrl+p"), key.WithHelp("↑", "previous item"), ), + Close: key.NewBinding( + key.WithKeys("esc"), + key.WithHelp("esc", "cancel"), + ), } } @@ -48,9 +53,6 @@ func (k KeyMap) ShortHelp() []key.Binding { key.WithHelp("↑↓", "choose"), ), k.Select, - key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "cancel"), - ), + k.Close, } } diff --git a/internal/tui/components/dialogs/sessions/sessions.go b/internal/tui/components/dialogs/sessions/sessions.go index e64de9b2ccdfd974724f9f12bf8745072df01333..31a8c8c2bf916db16333f5b152ac78a0e4b98d30 100644 --- a/internal/tui/components/dialogs/sessions/sessions.go +++ b/internal/tui/components/dialogs/sessions/sessions.go @@ -15,7 +15,7 @@ import ( "github.com/opencode-ai/opencode/internal/tui/util" ) -const id dialogs.DialogID = "sessions" +const SessionsDialogID dialogs.DialogID = "sessions" // SessionDialog interface for the session switching dialog type SessionDialog interface { @@ -113,6 +113,8 @@ func (s *sessionDialogCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { ), ) } + case key.Matches(msg, s.keyMap.Close): + return s, util.CmdHandler(dialogs.CloseDialogMsg{}) default: u, cmd := s.sessionsList.Update(msg) s.sessionsList = u.(list.ListModel) @@ -174,5 +176,5 @@ func (s *sessionDialogCmp) moveCursor(cursor *tea.Cursor) *tea.Cursor { // ID implements SessionDialog. func (s *sessionDialogCmp) ID() dialogs.DialogID { - return id + return SessionsDialogID } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 8fe13c3986f30ada4a8ac9a2661044e913eda6b3..96dbab01400f622e8d3e224e3f626f206d4ab68f 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -10,6 +10,7 @@ type KeyMap struct { Quit key.Binding Help key.Binding Commands key.Binding + Sessions key.Binding FilePicker key.Binding } @@ -32,6 +33,10 @@ func DefaultKeyMap() KeyMap { key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "commands"), ), + Sessions: key.NewBinding( + key.WithKeys("ctrl+s"), + key.WithHelp("ctrl+s", "sessions"), + ), FilePicker: key.NewBinding( key.WithKeys("ctrl+f"), key.WithHelp("ctrl+f", "select files to upload"), diff --git a/internal/tui/tui.go b/internal/tui/tui.go index d9d2dc5c728bf775b4c05f7440f32c894e6be0c9..71e0ea7dc64b0a1e2ffa094564f8287e440f6452 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -189,14 +189,41 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { return cmd // dialogs case key.Matches(msg, a.keyMap.Quit): + if a.dialog.ActiveDialogId() == quit.QuitDialogID { + // if the quit dialog is already open, close the app + return tea.Quit + } return util.CmdHandler(dialogs.OpenDialogMsg{ Model: quit.NewQuitDialog(), }) case key.Matches(msg, a.keyMap.Commands): + if a.dialog.ActiveDialogId() == commands.CommandsDialogID { + // If the commands dialog is already open, close it + return util.CmdHandler(dialogs.CloseDialogMsg{}) + } return util.CmdHandler(dialogs.OpenDialogMsg{ Model: commands.NewCommandDialog(), }) + case key.Matches(msg, a.keyMap.Sessions): + if a.dialog.ActiveDialogId() == sessions.SessionsDialogID { + // If the sessions dialog is already open, close it + return util.CmdHandler(dialogs.CloseDialogMsg{}) + } + var cmds []tea.Cmd + if a.dialog.ActiveDialogId() == commands.CommandsDialogID { + // If the commands dialog is open, close it first + cmds = append(cmds, util.CmdHandler(dialogs.CloseDialogMsg{})) + } + cmds = append(cmds, + func() tea.Msg { + allSessions, _ := a.app.Sessions.List(context.Background()) + return dialogs.OpenDialogMsg{ + Model: sessions.NewSessionDialogCmp(allSessions, a.selectedSessionID), + } + }, + ) + return tea.Sequence(cmds...) // Page navigation case key.Matches(msg, a.keyMap.Logs): return a.moveToPage(page.LogsPage)