Detailed changes
@@ -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 {
@@ -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,
}
}
@@ -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
}
@@ -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,
}
}
@@ -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() {
@@ -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,
}
}
@@ -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
}
@@ -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"),
+ ),
}
}
@@ -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
}
@@ -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,
}
}
@@ -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
}
@@ -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"),
@@ -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)