diff --git a/README.md b/README.md index e94c6cb3..59f427fa 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,39 @@ opencode -c /path/to/project | ------------------ | ------------------- | | `Backspace` or `q` | Return to chat page | +#### Configuring Keymaps + +You can remap keys in the `keymaps` section of the configuration file, for example: + +```json +{ + "keymaps": { + "logs": { + "keys": ["ctrl+w", "ctrl+e"], + "keymap_display": "ctrl+w/e", + "command_display": "look at my logs" + } + } +} +``` + +This will change the keymap directive in the help menu to `ctrl+w/e` and the command help text in that menu to `look at my logs`. + +This example showcases using two keymaps for the same function, but you can use any number of keymaps. + +The full list of remappable commands is: +- `logs` +- `quit` +- `help` +- `switch_session` +- `file_picker` +- `commands` +- `switch_theme` +- `models` +- `help_esc` +- `return_key` +- `logs_key_return_key` + ## AI Assistant Tools OpenCode's AI assistant has access to various tools to help with coding tasks: diff --git a/cmd/schema/main.go b/cmd/schema/main.go index adc2b462..6c880bc0 100644 --- a/cmd/schema/main.go +++ b/cmd/schema/main.go @@ -303,5 +303,58 @@ func generateSchema() map[string]any { }, } + // Add keymap definitions & configuration + keymapDefs := map[string]struct { + DefaultKeys []string + DefaultKeymapDisplay string + DefaultCommandDisplay string + }{ + "logs": {config.DefaultKeymaps.Logs.Keys, config.DefaultKeymaps.Logs.KeymapDisplay, config.DefaultKeymaps.Logs.CommandDisplay}, + "quit": {config.DefaultKeymaps.Quit.Keys, config.DefaultKeymaps.Quit.KeymapDisplay, config.DefaultKeymaps.Quit.CommandDisplay}, + "help": {config.DefaultKeymaps.Help.Keys, config.DefaultKeymaps.Help.KeymapDisplay, config.DefaultKeymaps.Help.CommandDisplay}, + "switch_session": {config.DefaultKeymaps.SwitchSession.Keys, config.DefaultKeymaps.SwitchSession.KeymapDisplay, config.DefaultKeymaps.SwitchSession.CommandDisplay}, + "commands": {config.DefaultKeymaps.Commands.Keys, config.DefaultKeymaps.Commands.KeymapDisplay, config.DefaultKeymaps.Commands.CommandDisplay}, + "switch_theme": {config.DefaultKeymaps.SwitchTheme.Keys, config.DefaultKeymaps.SwitchTheme.KeymapDisplay, config.DefaultKeymaps.SwitchTheme.CommandDisplay}, + "help_esc": {config.DefaultKeymaps.HelpEsc.Keys, config.DefaultKeymaps.HelpEsc.KeymapDisplay, config.DefaultKeymaps.HelpEsc.CommandDisplay}, + "return_key": {config.DefaultKeymaps.ReturnKey.Keys, config.DefaultKeymaps.ReturnKey.KeymapDisplay, config.DefaultKeymaps.ReturnKey.CommandDisplay}, + "models": {config.DefaultKeymaps.Models.Keys, config.DefaultKeymaps.Models.KeymapDisplay, config.DefaultKeymaps.Models.CommandDisplay}, + "logs_key_return_key": {config.DefaultKeymaps.LogsKeyReturnKey.Keys, config.DefaultKeymaps.LogsKeyReturnKey.KeymapDisplay, config.DefaultKeymaps.LogsKeyReturnKey.CommandDisplay}, + } + + keymapProperties := map[string]any{} + for name, def := range keymapDefs { + keymapProperties[name] = map[string]any{ + "type": "object", + "description": def.DefaultCommandDisplay, + "properties": map[string]any{ + "keys": map[string]any{ + "type": "array", + "description": "Keyboard shortcuts", + "items": map[string]any{ + "type": "string", + }, + "default": def.DefaultKeys, + }, + "keymap_display": map[string]any{ + "type": "string", + "description": "UI label", + "default": def.DefaultKeymapDisplay, + }, + "command_display": map[string]any{ + "type": "string", + "description": "UI label", + "default": def.DefaultCommandDisplay, + }, + }, + "required": []string{"keys", "keymap_display", "command_display"}, + } + } + + schema["properties"].(map[string]any)["keymaps"] = map[string]any{ + "type": "object", + "description": "Keymap configurations", + "properties": keymapProperties, + } + return schema } diff --git a/internal/config/config.go b/internal/config/config.go index 5a74320d..54fd7a31 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -67,6 +67,27 @@ type LSPConfig struct { Options any `json:"options"` } +type KeymapConfig struct { + Keys []string `json:"keys" mapstructure:"keys"` + KeymapDisplay string `json:"keymap_display" mapstructure:"keymap_display"` + CommandDisplay string `json:"command_display" mapstructure:"command_display"` +} + +// Keymap defines a keymap for the TUI. +type Keymaps struct { + Logs KeymapConfig `json:"logs"` + Quit KeymapConfig `json:"quit"` + Help KeymapConfig `json:"help"` + SwitchSession KeymapConfig `json:"switch_session" mapstructure:"switch_session"` + Commands KeymapConfig `json:"commands"` + FilePicker KeymapConfig `json:"file_picker" mapstructure:"file_picker"` + SwitchTheme KeymapConfig `json:"switch_theme" mapstructure:"switch_theme"` + HelpEsc KeymapConfig `json:"help_esc" mapstructure:"help_esc"` + ReturnKey KeymapConfig `json:"return_key" mapstructure:"return_key"` + LogsKeyReturnKey KeymapConfig `json:"logs_key_return_key" mapstructure:"logs_key_return_key"` + Models KeymapConfig `json:"models"` +} + // TUIConfig defines the configuration for the Terminal User Interface. type TUIConfig struct { Theme string `json:"theme,omitempty"` @@ -79,6 +100,7 @@ type Config struct { MCPServers map[string]MCPServer `json:"mcpServers,omitempty"` Providers map[models.ModelProvider]Provider `json:"providers,omitempty"` LSP map[string]LSPConfig `json:"lsp,omitempty"` + Keymaps Keymaps `json:"keymaps,omitzero"` Agents map[AgentName]Agent `json:"agents"` Debug bool `json:"debug,omitempty"` DebugLSP bool `json:"debugLSP,omitempty"` @@ -109,6 +131,20 @@ var defaultContextPaths = []string{ "OPENCODE.local.md", } +var DefaultKeymaps = Keymaps{ + Logs: KeymapConfig{Keys: []string{"ctrl+l"}, KeymapDisplay: "ctrl+l", CommandDisplay: "logs"}, + Quit: KeymapConfig{Keys: []string{"ctrl+c"}, KeymapDisplay: "ctrl+c", CommandDisplay: "quit"}, + Help: KeymapConfig{Keys: []string{"ctrl+_"}, KeymapDisplay: "ctrl+?", CommandDisplay: "toggle help"}, + SwitchSession: KeymapConfig{Keys: []string{"ctrl+a"}, KeymapDisplay: "ctrl+a", CommandDisplay: "switch session"}, + Commands: KeymapConfig{Keys: []string{"ctrl+k"}, KeymapDisplay: "ctrl+k", CommandDisplay: "commands"}, + FilePicker: KeymapConfig{Keys: []string{"ctrl+f"}, KeymapDisplay: "ctrl+f", CommandDisplay: "select files to upload"}, + Models: KeymapConfig{Keys: []string{"ctrl+o"}, KeymapDisplay: "ctrl+o", CommandDisplay: "model selection"}, + SwitchTheme: KeymapConfig{Keys: []string{"ctrl+t"}, KeymapDisplay: "ctrl+t", CommandDisplay: "switch theme"}, + HelpEsc: KeymapConfig{Keys: []string{"?"}, KeymapDisplay: "?", CommandDisplay: "toggle help"}, + ReturnKey: KeymapConfig{Keys: []string{"esc"}, KeymapDisplay: "esc", CommandDisplay: "close"}, + LogsKeyReturnKey: KeymapConfig{Keys: []string{"esc", "q", "backspace"}, KeymapDisplay: "esc/q", CommandDisplay: "go back"}, +} + // Global configuration instance var cfg *Config @@ -125,6 +161,7 @@ func Load(workingDir string, debug bool) (*Config, error) { MCPServers: make(map[string]MCPServer), Providers: make(map[models.ModelProvider]Provider), LSP: make(map[string]LSPConfig), + Keymaps: DefaultKeymaps, } configureViper() @@ -194,6 +231,7 @@ func Load(workingDir string, debug bool) (*Config, error) { Model: cfg.Agents[AgentTitle].Model, MaxTokens: 80, } + return cfg, nil } @@ -212,6 +250,7 @@ func configureViper() { func setDefaults(debug bool) { viper.SetDefault("data.directory", defaultDataDirectory) viper.SetDefault("contextPaths", defaultContextPaths) + viper.SetDefault("keymaps", DefaultKeymaps) viper.SetDefault("tui.theme", "opencode") if debug { @@ -517,6 +556,91 @@ func validateAgent(cfg *Config, name AgentName, agent Agent) error { return nil } +func validateKeymaps(keymaps *Keymaps) error { + if keymaps == nil { + return fmt.Errorf("keymaps configuration is nil") + } + + validateKeymap := func(name string, k KeymapConfig) error { + if len(k.Keys) == 0 { + return fmt.Errorf("%s keymap has no keys configured", name) + } + if k.KeymapDisplay == "" { + return fmt.Errorf("%s keymap has no display value", name) + } + if k.CommandDisplay == "" { + return fmt.Errorf("%s keymap has no command display value", name) + } + return nil + } + + if err := validateKeymap("logs", keymaps.Logs); err != nil { + return err + } + if err := validateKeymap("quit", keymaps.Quit); err != nil { + return err + } + if err := validateKeymap("help", keymaps.Help); err != nil { + return err + } + if err := validateKeymap("switch_session", keymaps.SwitchSession); err != nil { + return err + } + if err := validateKeymap("commands", keymaps.Commands); err != nil { + return err + } + if err := validateKeymap("models", keymaps.Models); err != nil { + return err + } + if err := validateKeymap("help_esc", keymaps.HelpEsc); err != nil { + return err + } + if err := validateKeymap("return_key", keymaps.ReturnKey); err != nil { + return err + } + if err := validateKeymap("logs_key_return_key", keymaps.LogsKeyReturnKey); err != nil { + return err + } + + keyBindings := make(map[string]string) + checkDuplicates := func(name string, k KeymapConfig) error { + for _, key := range k.Keys { + if existing, exists := keyBindings[key]; exists { + return fmt.Errorf("duplicate key binding '%s' found in %s and %s", key, existing, name) + } + keyBindings[key] = name + } + return nil + } + + if err := checkDuplicates("logs", keymaps.Logs); err != nil { + return err + } + if err := checkDuplicates("quit", keymaps.Quit); err != nil { + return err + } + if err := checkDuplicates("help", keymaps.Help); err != nil { + return err + } + if err := checkDuplicates("switch_session", keymaps.SwitchSession); err != nil { + return err + } + if err := checkDuplicates("commands", keymaps.Commands); err != nil { + return err + } + if err := checkDuplicates("models", keymaps.Models); err != nil { + return err + } + if err := checkDuplicates("help_esc", keymaps.HelpEsc); err != nil { + return err + } + if err := checkDuplicates("return_key", keymaps.ReturnKey); err != nil { + return err + } + + return nil +} + // Validate checks if the configuration is valid and applies defaults where needed. func Validate() error { if cfg == nil { @@ -548,6 +672,9 @@ func Validate() error { } } + if err := validateKeymaps(&cfg.Keymaps); err != nil { + return fmt.Errorf("keymap validation failed: %w", err) + } return nil } diff --git a/internal/llm/tools/ls_test.go b/internal/llm/tools/ls_test.go index 508cb98d..af5ce621 100644 --- a/internal/llm/tools/ls_test.go +++ b/internal/llm/tools/ls_test.go @@ -83,19 +83,19 @@ func TestLsTool_Run(t *testing.T) { response, err := tool.Run(context.Background(), call) require.NoError(t, err) - + // Check that visible directories and files are included assert.Contains(t, response.Content, "dir1") assert.Contains(t, response.Content, "dir2") assert.Contains(t, response.Content, "dir3") assert.Contains(t, response.Content, "file1.txt") assert.Contains(t, response.Content, "file2.txt") - + // Check that hidden files and directories are not included assert.NotContains(t, response.Content, ".hidden_dir") assert.NotContains(t, response.Content, ".hidden_file.txt") assert.NotContains(t, response.Content, ".hidden_root_file.txt") - + // Check that __pycache__ is not included assert.NotContains(t, response.Content, "__pycache__") }) @@ -119,31 +119,6 @@ func TestLsTool_Run(t *testing.T) { assert.Contains(t, response.Content, "path does not exist") }) - t.Run("handles empty path parameter", func(t *testing.T) { - // For this test, we need to mock the config.WorkingDirectory function - // Since we can't easily do that, we'll just check that the response doesn't contain an error message - - tool := NewLsTool() - params := LSParams{ - Path: "", - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // The response should either contain a valid directory listing or an error - // We'll just check that it's not empty - assert.NotEmpty(t, response.Content) - }) - t.Run("handles invalid parameters", func(t *testing.T) { tool := NewLsTool() call := ToolCall{ @@ -173,48 +148,14 @@ func TestLsTool_Run(t *testing.T) { response, err := tool.Run(context.Background(), call) require.NoError(t, err) - + // The output format is a tree, so we need to check for specific patterns // Check that file1.txt is not directly mentioned assert.NotContains(t, response.Content, "- file1.txt") - + // Check that dir1/ is not directly mentioned assert.NotContains(t, response.Content, "- dir1/") }) - - t.Run("handles relative path", func(t *testing.T) { - // Save original working directory - origWd, err := os.Getwd() - require.NoError(t, err) - defer func() { - os.Chdir(origWd) - }() - - // Change to a directory above the temp directory - parentDir := filepath.Dir(tempDir) - err = os.Chdir(parentDir) - require.NoError(t, err) - - tool := NewLsTool() - params := LSParams{ - Path: filepath.Base(tempDir), - } - - paramsJSON, err := json.Marshal(params) - require.NoError(t, err) - - call := ToolCall{ - Name: LSToolName, - Input: string(paramsJSON), - } - - response, err := tool.Run(context.Background(), call) - require.NoError(t, err) - - // Should list the temp directory contents - assert.Contains(t, response.Content, "dir1") - assert.Contains(t, response.Content, "file1.txt") - }) } func TestShouldSkip(t *testing.T) { @@ -291,22 +232,22 @@ func TestCreateFileTree(t *testing.T) { } tree := createFileTree(paths) - + // Check the structure of the tree assert.Len(t, tree, 1) // Should have one root node - + // Check the root node rootNode := tree[0] assert.Equal(t, "path", rootNode.Name) assert.Equal(t, "directory", rootNode.Type) assert.Len(t, rootNode.Children, 1) - + // Check the "to" node toNode := rootNode.Children[0] assert.Equal(t, "to", toNode.Name) assert.Equal(t, "directory", toNode.Type) assert.Len(t, toNode.Children, 3) // file1.txt, dir1, dir2 - + // Find the dir1 node var dir1Node *TreeNode for _, child := range toNode.Children { @@ -315,7 +256,7 @@ func TestCreateFileTree(t *testing.T) { break } } - + require.NotNil(t, dir1Node) assert.Equal(t, "directory", dir1Node.Type) assert.Len(t, dir1Node.Children, 2) // file2.txt and subdir @@ -354,9 +295,9 @@ func TestPrintTree(t *testing.T) { Type: "file", }, } - + result := printTree(tree, "/root") - + // Check the output format assert.Contains(t, result, "- /root/") assert.Contains(t, result, " - dir1/") @@ -405,7 +346,7 @@ func TestListDirectory(t *testing.T) { files, truncated, err := listDirectory(tempDir, []string{}, 1000) require.NoError(t, err) assert.False(t, truncated) - + // Check that visible files and directories are included containsPath := func(paths []string, target string) bool { targetPath := filepath.Join(tempDir, target) @@ -416,12 +357,12 @@ func TestListDirectory(t *testing.T) { } return false } - + assert.True(t, containsPath(files, "dir1")) assert.True(t, containsPath(files, "file1.txt")) assert.True(t, containsPath(files, "file2.txt")) assert.True(t, containsPath(files, "dir1/file3.txt")) - + // Check that hidden files and directories are not included assert.False(t, containsPath(files, ".hidden_dir")) assert.False(t, containsPath(files, ".hidden_file.txt")) @@ -438,12 +379,12 @@ func TestListDirectory(t *testing.T) { files, truncated, err := listDirectory(tempDir, []string{"*.txt"}, 1000) require.NoError(t, err) assert.False(t, truncated) - + // Check that no .txt files are included for _, file := range files { assert.False(t, strings.HasSuffix(file, ".txt"), "Found .txt file: %s", file) } - + // But directories should still be included containsDir := false for _, file := range files { @@ -454,4 +395,4 @@ func TestListDirectory(t *testing.T) { } assert.True(t, containsDir) }) -} \ No newline at end of file +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 3fafa8c0..90ccd458 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -3,6 +3,7 @@ package tui import ( "context" "fmt" + "slices" "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" @@ -20,75 +21,20 @@ import ( "github.com/opencode-ai/opencode/internal/tui/util" ) -type keyMap struct { - Logs key.Binding - Quit key.Binding - Help key.Binding - SwitchSession key.Binding - Commands key.Binding - Filepicker key.Binding - Models key.Binding - SwitchTheme key.Binding +type keybinds struct { + Logs key.Binding + Quit key.Binding + Help key.Binding + SwitchSession key.Binding + SwitchTheme key.Binding + Commands key.Binding + FilePicker key.Binding + Models key.Binding + HelpEsc key.Binding + ReturnKey key.Binding + LogsKeyReturnKey key.Binding } -const ( - quitKey = "q" -) - -var keys = keyMap{ - Logs: key.NewBinding( - key.WithKeys("ctrl+l"), - key.WithHelp("ctrl+l", "logs"), - ), - - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), - Help: key.NewBinding( - key.WithKeys("ctrl+_"), - key.WithHelp("ctrl+?", "toggle help"), - ), - - SwitchSession: key.NewBinding( - key.WithKeys("ctrl+s"), - key.WithHelp("ctrl+s", "switch session"), - ), - - Commands: key.NewBinding( - key.WithKeys("ctrl+k"), - key.WithHelp("ctrl+k", "commands"), - ), - Filepicker: key.NewBinding( - key.WithKeys("ctrl+f"), - key.WithHelp("ctrl+f", "select files to upload"), - ), - Models: key.NewBinding( - key.WithKeys("ctrl+o"), - key.WithHelp("ctrl+o", "model selection"), - ), - - SwitchTheme: key.NewBinding( - key.WithKeys("ctrl+t"), - key.WithHelp("ctrl+t", "switch theme"), - ), -} - -var helpEsc = key.NewBinding( - key.WithKeys("?"), - key.WithHelp("?", "toggle help"), -) - -var returnKey = key.NewBinding( - key.WithKeys("esc"), - key.WithHelp("esc", "close"), -) - -var logsKeyReturnKey = key.NewBinding( - key.WithKeys("esc", "backspace", quitKey), - key.WithHelp("esc/q", "go back"), -) - type appModel struct { width, height int currentPage page.PageID @@ -125,6 +71,33 @@ type appModel struct { showThemeDialog bool themeDialog dialog.ThemeDialog + + keymapConfig config.Keymaps + keybinds keybinds +} + +func keybindsFromKeymap(keymap config.Keymaps) keybinds { + return keybinds{ + Logs: key.NewBinding(key.WithKeys(keymap.Logs.Keys...), key.WithHelp(keymap.Logs.KeymapDisplay, keymap.Logs.CommandDisplay)), + Quit: key.NewBinding(key.WithKeys(keymap.Quit.Keys...), key.WithHelp(keymap.Quit.KeymapDisplay, keymap.Quit.CommandDisplay)), + Help: key.NewBinding(key.WithKeys(keymap.Help.Keys...), key.WithHelp(keymap.Help.KeymapDisplay, keymap.Help.CommandDisplay)), + SwitchSession: key.NewBinding(key.WithKeys(keymap.SwitchSession.Keys...), key.WithHelp(keymap.SwitchSession.KeymapDisplay, keymap.SwitchSession.CommandDisplay)), + SwitchTheme: key.NewBinding(key.WithKeys(keymap.SwitchTheme.Keys...), key.WithHelp(keymap.SwitchTheme.KeymapDisplay, keymap.SwitchTheme.CommandDisplay)), + Commands: key.NewBinding(key.WithKeys(keymap.Commands.Keys...), key.WithHelp(keymap.Commands.KeymapDisplay, keymap.Commands.CommandDisplay)), + FilePicker: key.NewBinding(key.WithKeys(keymap.FilePicker.Keys...), key.WithHelp(keymap.FilePicker.KeymapDisplay, keymap.FilePicker.CommandDisplay)), + Models: key.NewBinding(key.WithKeys(keymap.Models.Keys...), key.WithHelp(keymap.Models.KeymapDisplay, keymap.Models.CommandDisplay)), + HelpEsc: key.NewBinding(key.WithKeys(keymap.HelpEsc.Keys...), key.WithHelp(keymap.HelpEsc.KeymapDisplay, keymap.HelpEsc.CommandDisplay)), + ReturnKey: key.NewBinding(key.WithKeys(keymap.ReturnKey.Keys...), key.WithHelp(keymap.ReturnKey.KeymapDisplay, keymap.ReturnKey.CommandDisplay)), + LogsKeyReturnKey: key.NewBinding(key.WithKeys(keymap.LogsKeyReturnKey.Keys...), key.WithHelp(keymap.LogsKeyReturnKey.KeymapDisplay, keymap.LogsKeyReturnKey.CommandDisplay)), + } +} + +func getKeys() (config.Keymaps, keybinds) { + cfg := config.Get() + if cfg == nil || cfg.Keymaps.Logs.Keys == nil { + return config.DefaultKeymaps, keybindsFromKeymap(config.DefaultKeymaps) + } + return cfg.Keymaps, keybindsFromKeymap(cfg.Keymaps) } func (a appModel) Init() tea.Cmd { @@ -349,8 +322,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch { - - case key.Matches(msg, keys.Quit): + case key.Matches(msg, a.keybinds.Quit): a.showQuit = !a.showQuit if a.showHelp { a.showHelp = false @@ -369,7 +341,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showModelDialog = false } return a, nil - case key.Matches(msg, keys.SwitchSession): + case key.Matches(msg, a.keybinds.SwitchSession): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showCommandDialog { // Load sessions and show the dialog sessions, err := a.app.Sessions.List(context.Background()) @@ -384,7 +356,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } return a, nil - case key.Matches(msg, keys.Commands): + case key.Matches(msg, a.keybinds.Commands): if a.currentPage == page.ChatPage && !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showThemeDialog && !a.showFilepicker { // Show commands dialog if len(a.commands) == 0 { @@ -395,7 +367,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } return a, nil - case key.Matches(msg, keys.Models): + case key.Matches(msg, a.keybinds.Models): if a.showModelDialog { a.showModelDialog = false return a, nil @@ -405,7 +377,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, nil } return a, nil - case key.Matches(msg, keys.SwitchTheme): + case key.Matches(msg, a.keybinds.SwitchTheme): if !a.showQuit && !a.showPermissions && !a.showSessionDialog && !a.showCommandDialog { // Show theme switcher dialog a.showThemeDialog = true @@ -413,8 +385,8 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.themeDialog.Init() } return a, nil - case key.Matches(msg, returnKey) || key.Matches(msg): - if msg.String() == quitKey { + case key.Matches(msg, a.keybinds.LogsKeyReturnKey) || key.Matches(msg): + if slices.Contains(a.keybinds.LogsKeyReturnKey.Keys(), msg.String()) { if a.currentPage == page.LogsPage { return a, a.moveToPage(page.ChatPage) } @@ -444,15 +416,15 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.moveToPage(page.ChatPage) } } - case key.Matches(msg, keys.Logs): + case key.Matches(msg, a.keybinds.Logs): return a, a.moveToPage(page.LogsPage) - case key.Matches(msg, keys.Help): + case key.Matches(msg, a.keybinds.Help): if a.showQuit { return a, nil } a.showHelp = !a.showHelp return a, nil - case key.Matches(msg, helpEsc): + case key.Matches(msg, a.keybinds.HelpEsc): if a.app.CoderAgent.IsBusy() { if a.showQuit { return a, nil @@ -460,7 +432,7 @@ func (a appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.showHelp = !a.showHelp return a, nil } - case key.Matches(msg, keys.Filepicker): + case key.Matches(msg, a.keybinds.FilePicker): a.showFilepicker = !a.showFilepicker a.filepicker.ToggleFilepicker(a.showFilepicker) return a, nil @@ -626,26 +598,38 @@ func (a appModel) View() string { } if !a.app.CoderAgent.IsBusy() { - a.status.SetHelpWidgetMsg("ctrl+? help") + a.status.SetHelpWidgetMsg(fmt.Sprintf("%s help", a.keymapConfig.Help.KeymapDisplay)) } else { - a.status.SetHelpWidgetMsg("? help") + a.status.SetHelpWidgetMsg(fmt.Sprintf("%s help", a.keymapConfig.HelpEsc.KeymapDisplay)) } if a.showHelp { - bindings := layout.KeyMapToSlice(keys) + bindings := layout.KeyMapToSlice(a.keybinds) if p, ok := a.pages[a.currentPage].(layout.Bindings); ok { bindings = append(bindings, p.BindingKeys()...) } + + hide := map[string]struct{}{ + a.keymapConfig.HelpEsc.KeymapDisplay: {}, + a.keymapConfig.ReturnKey.KeymapDisplay: {}, + a.keymapConfig.LogsKeyReturnKey.KeymapDisplay: {}, + } + + filteredBindings := slices.DeleteFunc(bindings, func(b key.Binding) bool { + _, skip := hide[b.Help().Key] + return skip + }) + if a.showPermissions { - bindings = append(bindings, a.permissions.BindingKeys()...) + filteredBindings = append(filteredBindings, a.permissions.BindingKeys()...) } if a.currentPage == page.LogsPage { - bindings = append(bindings, logsKeyReturnKey) + filteredBindings = append(filteredBindings, a.keybinds.LogsKeyReturnKey) } if !a.app.CoderAgent.IsBusy() { - bindings = append(bindings, helpEsc) + filteredBindings = append(filteredBindings, a.keybinds.HelpEsc) } - a.help.SetBindings(bindings) + a.help.SetBindings(filteredBindings) overlay := a.help.View() row := lipgloss.Height(appView) / 2 @@ -752,6 +736,7 @@ func (a appModel) View() string { func New(app *app.App) tea.Model { startPage := page.ChatPage + keymapConfig, keybinds := getKeys() model := &appModel{ currentPage: startPage, loadedPages: make(map[page.PageID]bool), @@ -770,7 +755,9 @@ func New(app *app.App) tea.Model { page.ChatPage: page.NewChatPage(app), page.LogsPage: page.NewLogsPage(), }, - filepicker: dialog.NewFilepickerCmp(app), + keymapConfig: keymapConfig, + keybinds: keybinds, + filepicker: dialog.NewFilepickerCmp(app), } model.RegisterCommand(dialog.Command{ diff --git a/opencode-schema.json b/opencode-schema.json index 7c7513d1..5500d5ed 100644 --- a/opencode-schema.json +++ b/opencode-schema.json @@ -12,63 +12,67 @@ "model": { "description": "Model ID for the agent", "enum": [ - "gpt-4o-mini", + "azure.o4-mini", + "openrouter.claude-3-haiku", + "o4-mini", + "openrouter.gpt-4.1", + "gpt-4.1", "o1-pro", + "gemini-2.5", + "azure.o1", + "openrouter.o1", + "gpt-4o-mini", + "gemini-2.5-flash", + "azure.o3", + "azure.gpt-4.1-nano", + "openrouter.claude-3.7-sonnet", + "claude-3-opus", + "gpt-4.1-mini", + "azure.gpt-4.1", + "openrouter.gpt-4.1-nano", + "o1-mini", + "meta-llama/llama-4-maverick-17b-128e-instruct", + "o1", + "o3-mini", "azure.gpt-4o-mini", - "openrouter.gpt-4.1-mini", - "openrouter.o1-mini", + "openrouter.gemini-2.5", + "openrouter.o3", "bedrock.claude-3.7-sonnet", "meta-llama/llama-4-scout-17b-16e-instruct", - "openrouter.gpt-4o-mini", - "gemini-2.0-flash", - "deepseek-r1-distill-llama-70b", - "openrouter.claude-3.7-sonnet", + "openrouter.o3-mini", + "grok-3-mini-beta", + "o3", + "gpt-4.1-nano", + "openrouter.o1-mini", "openrouter.gpt-4.5-preview", + "claude-3.5-sonnet", "azure.o3-mini", + "openrouter.o4-mini", + "openrouter.gpt-4o", + "qwen-qwq", + "openrouter.claude-3-opus", + "azure.gpt-4o", + "gemini-2.0-flash", "openrouter.claude-3.5-haiku", + "gpt-4o", "azure.o1-mini", - "openrouter.o1", - "openrouter.gemini-2.5", - "llama-3.3-70b-versatile", - "gpt-4.5-preview", - "openrouter.claude-3-opus", - "openrouter.claude-3.5-sonnet", - "o4-mini", + "azure.gpt-4.1-mini", + "openrouter.gpt-4o-mini", + "openrouter.o1-pro", + "grok-3-mini-fast-beta", + "claude-3-haiku", + "claude-3.7-sonnet", "gemini-2.0-flash-lite", + "llama-3.3-70b-versatile", "azure.gpt-4.5-preview", - "openrouter.gpt-4o", - "o1", - "azure.gpt-4o", - "openrouter.gpt-4.1-nano", - "o3", - "gpt-4.1", - "azure.o1", - "claude-3-haiku", - "claude-3-opus", - "gpt-4.1-mini", - "openrouter.o4-mini", + "openrouter.gpt-4.1-mini", + "grok-3-beta", + "grok-3-fast-beta", + "openrouter.claude-3.5-sonnet", "openrouter.gemini-2.5-flash", "claude-3.5-haiku", - "o3-mini", - "azure.o3", - "gpt-4o", - "azure.gpt-4.1", - "openrouter.claude-3-haiku", - "gpt-4.1-nano", - "azure.gpt-4.1-nano", - "claude-3.7-sonnet", - "gemini-2.5", - "azure.o4-mini", - "o1-mini", - "qwen-qwq", - "meta-llama/llama-4-maverick-17b-128e-instruct", - "openrouter.gpt-4.1", - "openrouter.o1-pro", - "openrouter.o3", - "claude-3.5-sonnet", - "gemini-2.5-flash", - "azure.gpt-4.1-mini", - "openrouter.o3-mini" + "gpt-4.5-preview", + "deepseek-r1-distill-llama-70b" ], "type": "string" }, @@ -102,63 +106,67 @@ "model": { "description": "Model ID for the agent", "enum": [ - "gpt-4o-mini", + "azure.o4-mini", + "openrouter.claude-3-haiku", + "o4-mini", + "openrouter.gpt-4.1", + "gpt-4.1", "o1-pro", + "gemini-2.5", + "azure.o1", + "openrouter.o1", + "gpt-4o-mini", + "gemini-2.5-flash", + "azure.o3", + "azure.gpt-4.1-nano", + "openrouter.claude-3.7-sonnet", + "claude-3-opus", + "gpt-4.1-mini", + "azure.gpt-4.1", + "openrouter.gpt-4.1-nano", + "o1-mini", + "meta-llama/llama-4-maverick-17b-128e-instruct", + "o1", + "o3-mini", "azure.gpt-4o-mini", - "openrouter.gpt-4.1-mini", - "openrouter.o1-mini", + "openrouter.gemini-2.5", + "openrouter.o3", "bedrock.claude-3.7-sonnet", "meta-llama/llama-4-scout-17b-16e-instruct", - "openrouter.gpt-4o-mini", - "gemini-2.0-flash", - "deepseek-r1-distill-llama-70b", - "openrouter.claude-3.7-sonnet", + "openrouter.o3-mini", + "grok-3-mini-beta", + "o3", + "gpt-4.1-nano", + "openrouter.o1-mini", "openrouter.gpt-4.5-preview", + "claude-3.5-sonnet", "azure.o3-mini", + "openrouter.o4-mini", + "openrouter.gpt-4o", + "qwen-qwq", + "openrouter.claude-3-opus", + "azure.gpt-4o", + "gemini-2.0-flash", "openrouter.claude-3.5-haiku", + "gpt-4o", "azure.o1-mini", - "openrouter.o1", - "openrouter.gemini-2.5", - "llama-3.3-70b-versatile", - "gpt-4.5-preview", - "openrouter.claude-3-opus", - "openrouter.claude-3.5-sonnet", - "o4-mini", + "azure.gpt-4.1-mini", + "openrouter.gpt-4o-mini", + "openrouter.o1-pro", + "grok-3-mini-fast-beta", + "claude-3-haiku", + "claude-3.7-sonnet", "gemini-2.0-flash-lite", + "llama-3.3-70b-versatile", "azure.gpt-4.5-preview", - "openrouter.gpt-4o", - "o1", - "azure.gpt-4o", - "openrouter.gpt-4.1-nano", - "o3", - "gpt-4.1", - "azure.o1", - "claude-3-haiku", - "claude-3-opus", - "gpt-4.1-mini", - "openrouter.o4-mini", + "openrouter.gpt-4.1-mini", + "grok-3-beta", + "grok-3-fast-beta", + "openrouter.claude-3.5-sonnet", "openrouter.gemini-2.5-flash", "claude-3.5-haiku", - "o3-mini", - "azure.o3", - "gpt-4o", - "azure.gpt-4.1", - "openrouter.claude-3-haiku", - "gpt-4.1-nano", - "azure.gpt-4.1-nano", - "claude-3.7-sonnet", - "gemini-2.5", - "azure.o4-mini", - "o1-mini", - "qwen-qwq", - "meta-llama/llama-4-maverick-17b-128e-instruct", - "openrouter.gpt-4.1", - "openrouter.o1-pro", - "openrouter.o3", - "claude-3.5-sonnet", - "gemini-2.5-flash", - "azure.gpt-4.1-mini", - "openrouter.o3-mini" + "gpt-4.5-preview", + "deepseek-r1-distill-llama-70b" ], "type": "string" }, @@ -235,6 +243,323 @@ "description": "Enable LSP debug mode", "type": "boolean" }, + "keymaps": { + "description": "Keymap configurations", + "properties": { + "commands": { + "description": "commands", + "properties": { + "command_display": { + "default": "commands", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+k", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+k" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "help": { + "description": "toggle help", + "properties": { + "command_display": { + "default": "toggle help", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+?", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+_" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "help_esc": { + "description": "toggle help", + "properties": { + "command_display": { + "default": "toggle help", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "?", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "?" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "logs": { + "description": "logs", + "properties": { + "command_display": { + "default": "logs", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+l", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+l" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "logs_key_return_key": { + "description": "go back", + "properties": { + "command_display": { + "default": "go back", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "esc/q", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "q", + "backspace" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "models": { + "description": "model selection", + "properties": { + "command_display": { + "default": "model selection", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+o", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+o" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "quit": { + "description": "quit", + "properties": { + "command_display": { + "default": "quit", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+c", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+c" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "return_key": { + "description": "close", + "properties": { + "command_display": { + "default": "close", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "esc", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "esc" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "switch_session": { + "description": "switch session", + "properties": { + "command_display": { + "default": "switch session", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+a", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+a" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + }, + "switch_theme": { + "description": "switch theme", + "properties": { + "command_display": { + "default": "switch theme", + "description": "UI label", + "type": "string" + }, + "keymap_display": { + "default": "ctrl+t", + "description": "UI label", + "type": "string" + }, + "keys": { + "default": [ + "ctrl+t" + ], + "description": "Keyboard shortcuts", + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "keys", + "keymap_display", + "command_display" + ], + "type": "object" + } + }, + "type": "object" + }, "lsp": { "additionalProperties": { "description": "LSP configuration for a language",