package commands import ( "encoding/json" "log/slog" "slices" "strings" tea "github.com/charmbracelet/bubbletea/v2" "github.com/sst/opencode-sdk-go" ) type ExecuteCommandMsg Command type ExecuteCommandsMsg []Command type CommandExecutedMsg Command type Keybinding struct { RequiresLeader bool Key string } func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool { key := k.Key key = strings.TrimSpace(key) return key == msg.String() && (k.RequiresLeader == leader) } type CommandName string type Command struct { Name CommandName Description string Keybindings []Keybinding Trigger []string } func (c Command) Keys() []string { var keys []string for _, k := range c.Keybindings { keys = append(keys, k.Key) } return keys } func (c Command) HasTrigger() bool { return len(c.Trigger) > 0 } func (c Command) PrimaryTrigger() string { if len(c.Trigger) > 0 { return c.Trigger[0] } return "" } func (c Command) MatchesTrigger(trigger string) bool { return slices.Contains(c.Trigger, trigger) } type CommandRegistry map[CommandName]Command func (r CommandRegistry) Sorted() []Command { var commands []Command for _, command := range r { commands = append(commands, command) } slices.SortFunc(commands, func(a, b Command) int { // Priority order: session_new, session_share, model_list, agent_list, app_help first, app_exit last priorityOrder := map[CommandName]int{ SessionNewCommand: 0, AppHelpCommand: 1, SessionShareCommand: 2, ModelListCommand: 3, AgentListCommand: 4, } aPriority, aHasPriority := priorityOrder[a.Name] bPriority, bHasPriority := priorityOrder[b.Name] if aHasPriority && bHasPriority { return aPriority - bPriority } if aHasPriority { return -1 } if bHasPriority { return 1 } if a.Name == AppExitCommand { return 1 } if b.Name == AppExitCommand { return -1 } return strings.Compare(string(a.Name), string(b.Name)) }) return commands } func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command { var matched []Command for _, command := range r.Sorted() { if command.Matches(msg, leader) { matched = append(matched, command) } } return matched } const ( SessionChildCycleCommand CommandName = "session_child_cycle" SessionChildCycleReverseCommand CommandName = "session_child_cycle_reverse" ModelCycleRecentReverseCommand CommandName = "model_cycle_recent_reverse" AgentCycleCommand CommandName = "agent_cycle" AgentCycleReverseCommand CommandName = "agent_cycle_reverse" AppHelpCommand CommandName = "app_help" SwitchAgentCommand CommandName = "switch_agent" SwitchAgentReverseCommand CommandName = "switch_agent_reverse" EditorOpenCommand CommandName = "editor_open" SessionNewCommand CommandName = "session_new" SessionListCommand CommandName = "session_list" SessionNavigationCommand CommandName = "session_navigation" SessionShareCommand CommandName = "session_share" SessionUnshareCommand CommandName = "session_unshare" SessionInterruptCommand CommandName = "session_interrupt" SessionCompactCommand CommandName = "session_compact" SessionExportCommand CommandName = "session_export" ToolDetailsCommand CommandName = "tool_details" ThinkingBlocksCommand CommandName = "thinking_blocks" ModelListCommand CommandName = "model_list" AgentListCommand CommandName = "agent_list" ModelCycleRecentCommand CommandName = "model_cycle_recent" ThemeListCommand CommandName = "theme_list" FileListCommand CommandName = "file_list" FileCloseCommand CommandName = "file_close" FileSearchCommand CommandName = "file_search" FileDiffToggleCommand CommandName = "file_diff_toggle" ProjectInitCommand CommandName = "project_init" InputClearCommand CommandName = "input_clear" InputPasteCommand CommandName = "input_paste" InputSubmitCommand CommandName = "input_submit" InputNewlineCommand CommandName = "input_newline" MessagesPageUpCommand CommandName = "messages_page_up" MessagesPageDownCommand CommandName = "messages_page_down" MessagesHalfPageUpCommand CommandName = "messages_half_page_up" MessagesHalfPageDownCommand CommandName = "messages_half_page_down" MessagesPreviousCommand CommandName = "messages_previous" MessagesNextCommand CommandName = "messages_next" MessagesFirstCommand CommandName = "messages_first" MessagesLastCommand CommandName = "messages_last" MessagesLayoutToggleCommand CommandName = "messages_layout_toggle" MessagesCopyCommand CommandName = "messages_copy" MessagesUndoCommand CommandName = "messages_undo" MessagesRedoCommand CommandName = "messages_redo" AppExitCommand CommandName = "app_exit" ) func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool { for _, binding := range k.Keybindings { if binding.Matches(msg, leader) { return true } } return false } func parseBindings(bindings ...string) []Keybinding { var parsedBindings []Keybinding for _, binding := range bindings { if binding == "none" { continue } for p := range strings.SplitSeq(binding, ",") { requireLeader := strings.HasPrefix(p, "") keybinding := strings.ReplaceAll(p, "", "") keybinding = strings.TrimSpace(keybinding) parsedBindings = append(parsedBindings, Keybinding{ RequiresLeader: requireLeader, Key: keybinding, }) } } return parsedBindings } func LoadFromConfig(config *opencode.Config) CommandRegistry { defaults := []Command{ { Name: AppHelpCommand, Description: "show help", Keybindings: parseBindings("h"), Trigger: []string{"help"}, }, { Name: EditorOpenCommand, Description: "open editor", Keybindings: parseBindings("e"), Trigger: []string{"editor"}, }, { Name: SessionExportCommand, Description: "export conversation", Keybindings: parseBindings("x"), Trigger: []string{"export"}, }, { Name: SessionNewCommand, Description: "new session", Keybindings: parseBindings("n"), Trigger: []string{"new", "clear"}, }, { Name: SessionListCommand, Description: "list sessions", Keybindings: parseBindings("l"), Trigger: []string{"sessions", "resume", "continue"}, }, { Name: SessionNavigationCommand, Description: "jump to message", Keybindings: parseBindings("g"), Trigger: []string{"jump", "goto", "navigate"}, }, { Name: SessionShareCommand, Description: "share session", Keybindings: parseBindings("s"), Trigger: []string{"share"}, }, { Name: SessionUnshareCommand, Description: "unshare session", Trigger: []string{"unshare"}, }, { Name: SessionInterruptCommand, Description: "interrupt session", Keybindings: parseBindings("esc"), }, { Name: SessionCompactCommand, Description: "compact the session", Keybindings: parseBindings("c"), Trigger: []string{"compact", "summarize"}, }, { Name: SessionChildCycleCommand, Description: "cycle to next child session", Keybindings: parseBindings("ctrl+right"), }, { Name: SessionChildCycleReverseCommand, Description: "cycle to previous child session", Keybindings: parseBindings("ctrl+left"), }, { Name: ToolDetailsCommand, Description: "toggle tool details", Keybindings: parseBindings("d"), Trigger: []string{"details"}, }, { Name: ThinkingBlocksCommand, Description: "toggle thinking blocks", Keybindings: parseBindings("b"), Trigger: []string{"thinking"}, }, { Name: ModelListCommand, Description: "list models", Keybindings: parseBindings("m"), Trigger: []string{"models"}, }, { Name: ModelCycleRecentCommand, Description: "next recent model", Keybindings: parseBindings("f2"), }, { Name: ModelCycleRecentReverseCommand, Description: "previous recent model", Keybindings: parseBindings("shift+f2"), }, { Name: AgentListCommand, Description: "list agents", Keybindings: parseBindings("a"), Trigger: []string{"agents"}, }, { Name: AgentCycleCommand, Description: "next agent", Keybindings: parseBindings("tab"), }, { Name: AgentCycleReverseCommand, Description: "previous agent", Keybindings: parseBindings("shift+tab"), }, { Name: ThemeListCommand, Description: "list themes", Keybindings: parseBindings("t"), Trigger: []string{"themes"}, }, { Name: ProjectInitCommand, Description: "create/update AGENTS.md", Keybindings: parseBindings("i"), Trigger: []string{"init"}, }, { Name: InputClearCommand, Description: "clear input", Keybindings: parseBindings("ctrl+c"), }, { Name: InputPasteCommand, Description: "paste content", Keybindings: parseBindings("ctrl+v", "super+v"), }, { Name: InputSubmitCommand, Description: "submit message", Keybindings: parseBindings("enter"), }, { Name: InputNewlineCommand, Description: "insert newline", Keybindings: parseBindings("shift+enter", "ctrl+j"), }, { Name: MessagesPageUpCommand, Description: "page up", Keybindings: parseBindings("pgup"), }, { Name: MessagesPageDownCommand, Description: "page down", Keybindings: parseBindings("pgdown"), }, { Name: MessagesHalfPageUpCommand, Description: "half page up", Keybindings: parseBindings("ctrl+alt+u"), }, { Name: MessagesHalfPageDownCommand, Description: "half page down", Keybindings: parseBindings("ctrl+alt+d"), }, { Name: MessagesFirstCommand, Description: "first message", Keybindings: parseBindings("ctrl+g"), }, { Name: MessagesLastCommand, Description: "last message", Keybindings: parseBindings("ctrl+alt+g"), }, { Name: MessagesCopyCommand, Description: "copy message", Keybindings: parseBindings("y"), }, { Name: MessagesUndoCommand, Description: "undo last message", Keybindings: parseBindings("u"), Trigger: []string{"undo"}, }, { Name: MessagesRedoCommand, Description: "redo message", Keybindings: parseBindings("r"), Trigger: []string{"redo"}, }, { Name: AppExitCommand, Description: "exit the app", Keybindings: parseBindings("ctrl+c", "q"), Trigger: []string{"exit", "quit", "q"}, }, } registry := make(CommandRegistry) keybinds := map[string]string{} marshalled, _ := json.Marshal(config.Keybinds) json.Unmarshal(marshalled, &keybinds) for _, command := range defaults { // Remove share/unshare commands if sharing is disabled if config.Share == opencode.ConfigShareDisabled && (command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) { slog.Info("Removing share/unshare commands") continue } if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" { command.Keybindings = parseBindings(keybind) } registry[command.Name] = command } slog.Info("Loaded commands", "commands", registry) return registry }