Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@ type Config struct {
DebugLSP bool `json:"debugLSP,omitempty"`
ContextPaths []string `json:"contextPaths,omitempty"`
TUI TUIConfig `json:"tui"`
Shell ShellConfig `json:"shell,omitempty"`
Shell ShellConfig `json:"shell"`
KeyMaps *KeyMapConfig `json:"keyMaps,omitempty"`
}

// Application constants
Expand Down Expand Up @@ -155,6 +156,15 @@ func Load(workingDir string, debug bool, lvl *slog.LevelVar) (*Config, error) {
return cfg, fmt.Errorf("failed to unmarshal config: %w", err)
}

// Set default keymaps or merge with user-provided keymaps
if cfg.KeyMaps == nil {
cfg.KeyMaps = DefaultKeyMapConfig()
} else {
// Merge user-provided keymaps with default keymaps
defaultKeyMaps := DefaultKeyMapConfig()
mergeKeyMaps(cfg.KeyMaps, defaultKeyMaps)
}

applyDefaultValues()

defaultLevel := slog.LevelInfo
Expand Down
206 changes: 206 additions & 0 deletions internal/config/keymaps.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package config

import (
"github.com/charmbracelet/bubbles/key"
)

// KeyMapConfig defines the configuration for keyboard shortcuts
type KeyMapConfig struct {
// Chat keymaps
NewSession []string `json:"newSession,omitempty"`
Cancel []string `json:"cancel,omitempty"`
ToggleTools []string `json:"toggleTools,omitempty"`
ShowCompletionDialog []string `json:"showCompletionDialog,omitempty"`

// Global keymaps
ViewLogs []string `json:"viewLogs,omitempty"`
Quit []string `json:"quit,omitempty"`
Help []string `json:"help,omitempty"`
SwitchSession []string `json:"switchSession,omitempty"`
Commands []string `json:"commands,omitempty"`
FilePicker []string `json:"filePicker,omitempty"`
Models []string `json:"models,omitempty"`
Theme []string `json:"theme,omitempty"`
Tools []string `json:"tools,omitempty"`
}

// DefaultKeyMapConfig returns the default keyboard shortcut configuration
func DefaultKeyMapConfig() *KeyMapConfig {
return &KeyMapConfig{
// Chat keymaps
NewSession: []string{"ctrl+n"},
Cancel: []string{"esc"},
ToggleTools: []string{"ctrl+h"},
ShowCompletionDialog: []string{"/"},

// Global keymaps
ViewLogs: []string{"ctrl+l"},
Quit: []string{"ctrl+c"},
Help: []string{"ctrl+_"},
SwitchSession: []string{"ctrl+s"},
Commands: []string{"ctrl+k"},
FilePicker: []string{"ctrl+f"},
Models: []string{"ctrl+o"},
Theme: []string{"ctrl+t"},
Tools: []string{"f9"},
}
}

func (c *Config) GetAllKeyBinding() []key.Binding {
chatKeyMap := c.GetChatKeyMap()
globalKeyMap := c.GetGlobalKeyMap()

return []key.Binding{
chatKeyMap.NewSession,
chatKeyMap.Cancel,
chatKeyMap.ToggleTools,
chatKeyMap.ShowCompletionDialog,
globalKeyMap.ViewLogs,
globalKeyMap.Quit,
globalKeyMap.Help,
globalKeyMap.SwitchSession,
globalKeyMap.Commands,
globalKeyMap.FilePicker,
globalKeyMap.Models,
globalKeyMap.Theme,
globalKeyMap.Tools,
}
}

// GetChatKeyMap returns a ChatKeyMap with bindings from config
func (c *Config) GetChatKeyMap() ChatKeyMap {
keys := c.KeyMaps

out := ChatKeyMap{
NewSession: key.NewBinding(
key.WithKeys(keys.NewSession...),
key.WithHelp(keys.NewSession[0], "new session"),
),
Cancel: key.NewBinding(
key.WithKeys(keys.Cancel...),
key.WithHelp(keys.Cancel[0], "cancel"),
),
ToggleTools: key.NewBinding(
key.WithKeys(keys.ToggleTools...),
key.WithHelp(keys.ToggleTools[0], "toggle tools"),
),
ShowCompletionDialog: key.NewBinding(
key.WithKeys(keys.ShowCompletionDialog...),
key.WithHelp(keys.ShowCompletionDialog[0], "complete"),
),
}

return out
}

// ChatKeyMap defines key bindings for the chat page
type ChatKeyMap struct {
NewSession key.Binding
Cancel key.Binding
ToggleTools key.Binding
ShowCompletionDialog key.Binding
}

// GlobalKeyMap defines key bindings for global application functions
type GlobalKeyMap struct {
ViewLogs key.Binding
Quit key.Binding
Help key.Binding
SwitchSession key.Binding
Commands key.Binding
FilePicker key.Binding
Models key.Binding
Theme key.Binding
Tools key.Binding
}

// GetGlobalKeyMap returns a GlobalKeyMap with bindings from config
func (c *Config) GetGlobalKeyMap() GlobalKeyMap {
keys := c.KeyMaps

return GlobalKeyMap{
ViewLogs: key.NewBinding(
key.WithKeys(keys.ViewLogs...),
key.WithHelp(keys.ViewLogs[0], "logs"),
),
Quit: key.NewBinding(
key.WithKeys(keys.Quit...),
key.WithHelp(keys.Quit[0], "quit"),
),
Help: key.NewBinding(
key.WithKeys(keys.Help...),
key.WithHelp("ctrl+?", "toggle help"),
),
SwitchSession: key.NewBinding(
key.WithKeys(keys.SwitchSession...),
key.WithHelp(keys.SwitchSession[0], "switch session"),
),
Commands: key.NewBinding(
key.WithKeys(keys.Commands...),
key.WithHelp(keys.Commands[0], "commands"),
),
FilePicker: key.NewBinding(
key.WithKeys(keys.FilePicker...),
key.WithHelp(keys.FilePicker[0], "select files to upload"),
),
Models: key.NewBinding(
key.WithKeys(keys.Models...),
key.WithHelp(keys.Models[0], "model selection"),
),
Theme: key.NewBinding(
key.WithKeys(keys.Theme...),
key.WithHelp(keys.Theme[0], "switch theme"),
),
Tools: key.NewBinding(
key.WithKeys(keys.Tools...),
key.WithHelp(keys.Tools[0], "show available tools"),
),
}
}

// mergeKeyMaps merges user-provided keymaps with default keymaps
// If a keymap is not provided by the user, the default is used
func mergeKeyMaps(userKeyMaps, defaultKeyMaps *KeyMapConfig) {
// Chat keymaps
if userKeyMaps.NewSession == nil {
userKeyMaps.NewSession = defaultKeyMaps.NewSession
}
if userKeyMaps.Cancel == nil {
userKeyMaps.Cancel = defaultKeyMaps.Cancel
}
if userKeyMaps.ToggleTools == nil {
userKeyMaps.ToggleTools = defaultKeyMaps.ToggleTools
}
if userKeyMaps.ShowCompletionDialog == nil {
userKeyMaps.ShowCompletionDialog = defaultKeyMaps.ShowCompletionDialog
}

// Global keymaps
if userKeyMaps.ViewLogs == nil {
userKeyMaps.ViewLogs = defaultKeyMaps.ViewLogs
}
if userKeyMaps.Quit == nil {
userKeyMaps.Quit = defaultKeyMaps.Quit
}
if userKeyMaps.Help == nil {
userKeyMaps.Help = defaultKeyMaps.Help
}
if userKeyMaps.SwitchSession == nil {
userKeyMaps.SwitchSession = defaultKeyMaps.SwitchSession
}
if userKeyMaps.Commands == nil {
userKeyMaps.Commands = defaultKeyMaps.Commands
}
if userKeyMaps.FilePicker == nil {
userKeyMaps.FilePicker = defaultKeyMaps.FilePicker
}
if userKeyMaps.Models == nil {
userKeyMaps.Models = defaultKeyMaps.Models
}
if userKeyMaps.Theme == nil {
userKeyMaps.Theme = defaultKeyMaps.Theme
}
if userKeyMaps.Tools == nil {
userKeyMaps.Tools = defaultKeyMaps.Tools
}
}
6 changes: 3 additions & 3 deletions internal/diff/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ func TestApplyHighlighting(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
result := applyHighlighting(tc.content, tc.segments, tc.segmentType, mockHighlightBg)

// Verify the result contains the expected sequence
assert.Contains(t, result, tc.expectContains,
assert.Contains(t, result, tc.expectContains,
"Result should contain full reset sequence")

// Print the result for manual inspection if needed
if t.Failed() {
fmt.Printf("Original: %q\nResult: %q\n", tc.content, result)
Expand Down
24 changes: 12 additions & 12 deletions internal/tui/components/chat/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,10 @@ type editorCmp struct {
}

type EditorKeyMaps struct {
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
Send key.Binding
OpenEditor key.Binding
Paste key.Binding
HistoryUp key.Binding
HistoryDown key.Binding
}

Expand Down Expand Up @@ -251,14 +251,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryUp) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number
currentLine := m.textarea.Line()

// Only navigate history if we're at the first line
if currentLine == 0 && len(m.history) > 0 {
// Save current message if we're just starting to navigate
if m.historyIndex == len(m.history) {
m.currentMessage = m.textarea.Value()
}

// Go to previous message in history
if m.historyIndex > 0 {
m.historyIndex--
Expand All @@ -267,14 +267,14 @@ func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
}

if m.textarea.Focused() && key.Matches(msg, editorMaps.HistoryDown) && !m.app.IsFilepickerOpen() && !m.app.IsCompletionDialogOpen() {
// Get the current line number and total lines
currentLine := m.textarea.Line()
value := m.textarea.Value()
lines := strings.Split(value, "\n")
totalLines := len(lines)

// Only navigate history if we're at the last line
if currentLine == totalLines-1 {
if m.historyIndex < len(m.history)-1 {
Expand Down Expand Up @@ -403,10 +403,10 @@ func CreateTextArea(existing *textarea.Model) textarea.Model {
func NewEditorCmp(app *app.App) tea.Model {
ta := CreateTextArea(nil)
return &editorCmp{
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
app: app,
textarea: ta,
history: []string{},
historyIndex: 0,
currentMessage: "",
}
}
15 changes: 14 additions & 1 deletion internal/tui/components/chat/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"strings"
"time"

"github.com/charmbracelet/bubbles/key"
Expand All @@ -12,6 +13,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/sst/opencode/internal/app"
"github.com/sst/opencode/internal/config"
"github.com/sst/opencode/internal/message"
"github.com/sst/opencode/internal/pubsub"
"github.com/sst/opencode/internal/session"
Expand Down Expand Up @@ -370,6 +372,13 @@ func (m *messagesCmp) help() string {

text := ""

cfg := config.Get()
if cfg == nil {
return "failed to get config"
}

toggleTool := cfg.GetChatKeyMap().ToggleTools

if m.app.PrimaryAgent.IsBusy() {
text += lipgloss.JoinHorizontal(
lipgloss.Left,
Expand All @@ -388,7 +397,7 @@ func (m *messagesCmp) help() string {
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for newline,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ↑↓"),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" for history,"),
baseStyle.Foreground(t.Text()).Bold(true).Render(" ctrl+h"),
baseStyle.Foreground(t.Text()).Bold(true).Render(fmt.Sprintf(" %s", keyBindingString(toggleTool))),
baseStyle.Foreground(t.TextMuted()).Bold(true).Render(" to toggle tool messages"),
)
}
Expand Down Expand Up @@ -483,3 +492,7 @@ func NewMessagesCmp(app *app.App) tea.Model {
showToolMessages: true,
}
}

func keyBindingString(binding key.Binding) string {
return strings.Join(binding.Keys(), ", ")
}
Loading