add shortcuts

Kujtim Hoxha created

Change summary

internal/tui/components/completions/item.go          | 87 +++++++------
internal/tui/components/core/status/keys.go          |  6 
internal/tui/components/dialogs/commands/commands.go | 14 +
internal/tui/components/dialogs/commands/keys.go     | 18 +-
internal/tui/components/dialogs/dialogs.go           | 13 +
internal/tui/components/dialogs/models/keys.go       | 16 +-
internal/tui/components/dialogs/models/models.go     |  6 
internal/tui/components/dialogs/quit/keys.go         | 15 +
internal/tui/components/dialogs/quit/quit.go         | 10 
internal/tui/components/dialogs/sessions/keys.go     | 16 +-
internal/tui/components/dialogs/sessions/sessions.go |  6 
internal/tui/keys.go                                 |  5 
internal/tui/tui.go                                  | 27 ++++
13 files changed, 157 insertions(+), 82 deletions(-)

Detailed changes

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 {

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,
 	}
 }

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
 }

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,
 	}
 }
 

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() {

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,
 	}
 }

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
 }

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"),
+		),
 	}
 }
 

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
 }

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,
 	}
 }

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
 }

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"),

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)