mirror of
https://github.com/sst/opencode.git
synced 2025-08-22 22:14:14 +00:00
406 lines
12 KiB
Go
406 lines
12 KiB
Go
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, "<leader>")
|
|
keybinding := strings.ReplaceAll(p, "<leader>", "")
|
|
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("<leader>h"),
|
|
Trigger: []string{"help"},
|
|
},
|
|
{
|
|
Name: EditorOpenCommand,
|
|
Description: "open editor",
|
|
Keybindings: parseBindings("<leader>e"),
|
|
Trigger: []string{"editor"},
|
|
},
|
|
{
|
|
Name: SessionExportCommand,
|
|
Description: "export conversation",
|
|
Keybindings: parseBindings("<leader>x"),
|
|
Trigger: []string{"export"},
|
|
},
|
|
{
|
|
Name: SessionNewCommand,
|
|
Description: "new session",
|
|
Keybindings: parseBindings("<leader>n"),
|
|
Trigger: []string{"new", "clear"},
|
|
},
|
|
{
|
|
Name: SessionListCommand,
|
|
Description: "list sessions",
|
|
Keybindings: parseBindings("<leader>l"),
|
|
Trigger: []string{"sessions", "resume", "continue"},
|
|
},
|
|
{
|
|
Name: SessionNavigationCommand,
|
|
Description: "jump to message",
|
|
Keybindings: parseBindings("<leader>g"),
|
|
Trigger: []string{"jump", "goto", "navigate"},
|
|
},
|
|
{
|
|
Name: SessionShareCommand,
|
|
Description: "share session",
|
|
Keybindings: parseBindings("<leader>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("<leader>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("<leader>d"),
|
|
Trigger: []string{"details"},
|
|
},
|
|
{
|
|
Name: ThinkingBlocksCommand,
|
|
Description: "toggle thinking blocks",
|
|
Keybindings: parseBindings("<leader>b"),
|
|
Trigger: []string{"thinking"},
|
|
},
|
|
{
|
|
Name: ModelListCommand,
|
|
Description: "list models",
|
|
Keybindings: parseBindings("<leader>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("<leader>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("<leader>t"),
|
|
Trigger: []string{"themes"},
|
|
},
|
|
{
|
|
Name: ProjectInitCommand,
|
|
Description: "create/update AGENTS.md",
|
|
Keybindings: parseBindings("<leader>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("<leader>y"),
|
|
},
|
|
{
|
|
Name: MessagesUndoCommand,
|
|
Description: "undo last message",
|
|
Keybindings: parseBindings("<leader>u"),
|
|
Trigger: []string{"undo"},
|
|
},
|
|
{
|
|
Name: MessagesRedoCommand,
|
|
Description: "redo message",
|
|
Keybindings: parseBindings("<leader>r"),
|
|
Trigger: []string{"redo"},
|
|
},
|
|
{
|
|
Name: AppExitCommand,
|
|
Description: "exit the app",
|
|
Keybindings: parseBindings("ctrl+c", "<leader>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
|
|
}
|