command.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. package commands
  2. import (
  3. "encoding/json"
  4. "log/slog"
  5. "slices"
  6. "strings"
  7. tea "github.com/charmbracelet/bubbletea/v2"
  8. "github.com/sst/opencode-sdk-go"
  9. )
  10. type ExecuteCommandMsg Command
  11. type ExecuteCommandsMsg []Command
  12. type CommandExecutedMsg Command
  13. type Keybinding struct {
  14. RequiresLeader bool
  15. Key string
  16. }
  17. func (k Keybinding) Matches(msg tea.KeyPressMsg, leader bool) bool {
  18. key := k.Key
  19. key = strings.TrimSpace(key)
  20. return key == msg.String() && (k.RequiresLeader == leader)
  21. }
  22. type CommandName string
  23. type Command struct {
  24. Name CommandName
  25. Description string
  26. Keybindings []Keybinding
  27. Trigger []string
  28. }
  29. func (c Command) Keys() []string {
  30. var keys []string
  31. for _, k := range c.Keybindings {
  32. keys = append(keys, k.Key)
  33. }
  34. return keys
  35. }
  36. func (c Command) HasTrigger() bool {
  37. return len(c.Trigger) > 0
  38. }
  39. func (c Command) PrimaryTrigger() string {
  40. if len(c.Trigger) > 0 {
  41. return c.Trigger[0]
  42. }
  43. return ""
  44. }
  45. func (c Command) MatchesTrigger(trigger string) bool {
  46. return slices.Contains(c.Trigger, trigger)
  47. }
  48. type CommandRegistry map[CommandName]Command
  49. func (r CommandRegistry) Sorted() []Command {
  50. var commands []Command
  51. for _, command := range r {
  52. commands = append(commands, command)
  53. }
  54. slices.SortFunc(commands, func(a, b Command) int {
  55. // Priority order: session_new, session_share, model_list, app_help first, app_exit last
  56. priorityOrder := map[CommandName]int{
  57. SessionNewCommand: 0,
  58. AppHelpCommand: 1,
  59. SessionShareCommand: 2,
  60. ModelListCommand: 3,
  61. }
  62. aPriority, aHasPriority := priorityOrder[a.Name]
  63. bPriority, bHasPriority := priorityOrder[b.Name]
  64. if aHasPriority && bHasPriority {
  65. return aPriority - bPriority
  66. }
  67. if aHasPriority {
  68. return -1
  69. }
  70. if bHasPriority {
  71. return 1
  72. }
  73. if a.Name == AppExitCommand {
  74. return 1
  75. }
  76. if b.Name == AppExitCommand {
  77. return -1
  78. }
  79. return strings.Compare(string(a.Name), string(b.Name))
  80. })
  81. return commands
  82. }
  83. func (r CommandRegistry) Matches(msg tea.KeyPressMsg, leader bool) []Command {
  84. var matched []Command
  85. for _, command := range r.Sorted() {
  86. if command.Matches(msg, leader) {
  87. matched = append(matched, command)
  88. }
  89. }
  90. return matched
  91. }
  92. const (
  93. AppHelpCommand CommandName = "app_help"
  94. SwitchAgentCommand CommandName = "switch_agent"
  95. SwitchAgentReverseCommand CommandName = "switch_agent_reverse"
  96. EditorOpenCommand CommandName = "editor_open"
  97. SessionNewCommand CommandName = "session_new"
  98. SessionListCommand CommandName = "session_list"
  99. SessionShareCommand CommandName = "session_share"
  100. SessionUnshareCommand CommandName = "session_unshare"
  101. SessionInterruptCommand CommandName = "session_interrupt"
  102. SessionCompactCommand CommandName = "session_compact"
  103. SessionExportCommand CommandName = "session_export"
  104. ToolDetailsCommand CommandName = "tool_details"
  105. ModelListCommand CommandName = "model_list"
  106. ThemeListCommand CommandName = "theme_list"
  107. FileListCommand CommandName = "file_list"
  108. FileCloseCommand CommandName = "file_close"
  109. FileSearchCommand CommandName = "file_search"
  110. FileDiffToggleCommand CommandName = "file_diff_toggle"
  111. ProjectInitCommand CommandName = "project_init"
  112. InputClearCommand CommandName = "input_clear"
  113. InputPasteCommand CommandName = "input_paste"
  114. InputSubmitCommand CommandName = "input_submit"
  115. InputNewlineCommand CommandName = "input_newline"
  116. MessagesPageUpCommand CommandName = "messages_page_up"
  117. MessagesPageDownCommand CommandName = "messages_page_down"
  118. MessagesHalfPageUpCommand CommandName = "messages_half_page_up"
  119. MessagesHalfPageDownCommand CommandName = "messages_half_page_down"
  120. MessagesPreviousCommand CommandName = "messages_previous"
  121. MessagesNextCommand CommandName = "messages_next"
  122. MessagesFirstCommand CommandName = "messages_first"
  123. MessagesLastCommand CommandName = "messages_last"
  124. MessagesLayoutToggleCommand CommandName = "messages_layout_toggle"
  125. MessagesCopyCommand CommandName = "messages_copy"
  126. MessagesUndoCommand CommandName = "messages_undo"
  127. MessagesRedoCommand CommandName = "messages_redo"
  128. AppExitCommand CommandName = "app_exit"
  129. )
  130. func (k Command) Matches(msg tea.KeyPressMsg, leader bool) bool {
  131. for _, binding := range k.Keybindings {
  132. if binding.Matches(msg, leader) {
  133. return true
  134. }
  135. }
  136. return false
  137. }
  138. func parseBindings(bindings ...string) []Keybinding {
  139. var parsedBindings []Keybinding
  140. for _, binding := range bindings {
  141. if binding == "none" {
  142. continue
  143. }
  144. for p := range strings.SplitSeq(binding, ",") {
  145. requireLeader := strings.HasPrefix(p, "<leader>")
  146. keybinding := strings.ReplaceAll(p, "<leader>", "")
  147. keybinding = strings.TrimSpace(keybinding)
  148. parsedBindings = append(parsedBindings, Keybinding{
  149. RequiresLeader: requireLeader,
  150. Key: keybinding,
  151. })
  152. }
  153. }
  154. return parsedBindings
  155. }
  156. func LoadFromConfig(config *opencode.Config) CommandRegistry {
  157. defaults := []Command{
  158. {
  159. Name: AppHelpCommand,
  160. Description: "show help",
  161. Keybindings: parseBindings("<leader>h"),
  162. Trigger: []string{"help"},
  163. },
  164. {
  165. Name: SwitchAgentCommand,
  166. Description: "next agent",
  167. Keybindings: parseBindings("tab"),
  168. },
  169. {
  170. Name: SwitchAgentReverseCommand,
  171. Description: "previous agent",
  172. Keybindings: parseBindings("shift+tab"),
  173. },
  174. {
  175. Name: EditorOpenCommand,
  176. Description: "open editor",
  177. Keybindings: parseBindings("<leader>e"),
  178. Trigger: []string{"editor"},
  179. },
  180. {
  181. Name: SessionExportCommand,
  182. Description: "export conversation",
  183. Keybindings: parseBindings("<leader>x"),
  184. Trigger: []string{"export"},
  185. },
  186. {
  187. Name: SessionNewCommand,
  188. Description: "new session",
  189. Keybindings: parseBindings("<leader>n"),
  190. Trigger: []string{"new", "clear"},
  191. },
  192. {
  193. Name: SessionListCommand,
  194. Description: "list sessions",
  195. Keybindings: parseBindings("<leader>l"),
  196. Trigger: []string{"sessions", "resume", "continue"},
  197. },
  198. {
  199. Name: SessionShareCommand,
  200. Description: "share session",
  201. Keybindings: parseBindings("<leader>s"),
  202. Trigger: []string{"share"},
  203. },
  204. {
  205. Name: SessionUnshareCommand,
  206. Description: "unshare session",
  207. Trigger: []string{"unshare"},
  208. },
  209. {
  210. Name: SessionInterruptCommand,
  211. Description: "interrupt session",
  212. Keybindings: parseBindings("esc"),
  213. },
  214. {
  215. Name: SessionCompactCommand,
  216. Description: "compact the session",
  217. Keybindings: parseBindings("<leader>c"),
  218. Trigger: []string{"compact", "summarize"},
  219. },
  220. {
  221. Name: ToolDetailsCommand,
  222. Description: "toggle tool details",
  223. Keybindings: parseBindings("<leader>d"),
  224. Trigger: []string{"details"},
  225. },
  226. {
  227. Name: ModelListCommand,
  228. Description: "list models",
  229. Keybindings: parseBindings("<leader>m"),
  230. Trigger: []string{"models"},
  231. },
  232. {
  233. Name: ThemeListCommand,
  234. Description: "list themes",
  235. Keybindings: parseBindings("<leader>t"),
  236. Trigger: []string{"themes"},
  237. },
  238. // {
  239. // Name: FileListCommand,
  240. // Description: "list files",
  241. // Keybindings: parseBindings("<leader>f"),
  242. // Trigger: []string{"files"},
  243. // },
  244. {
  245. Name: FileCloseCommand,
  246. Description: "close file",
  247. Keybindings: parseBindings("esc"),
  248. },
  249. {
  250. Name: FileSearchCommand,
  251. Description: "search file",
  252. Keybindings: parseBindings("<leader>/"),
  253. },
  254. {
  255. Name: FileDiffToggleCommand,
  256. Description: "split/unified diff",
  257. Keybindings: parseBindings("<leader>v"),
  258. },
  259. {
  260. Name: ProjectInitCommand,
  261. Description: "create/update AGENTS.md",
  262. Keybindings: parseBindings("<leader>i"),
  263. Trigger: []string{"init"},
  264. },
  265. {
  266. Name: InputClearCommand,
  267. Description: "clear input",
  268. Keybindings: parseBindings("ctrl+c"),
  269. },
  270. {
  271. Name: InputPasteCommand,
  272. Description: "paste content",
  273. Keybindings: parseBindings("ctrl+v", "super+v"),
  274. },
  275. {
  276. Name: InputSubmitCommand,
  277. Description: "submit message",
  278. Keybindings: parseBindings("enter"),
  279. },
  280. {
  281. Name: InputNewlineCommand,
  282. Description: "insert newline",
  283. Keybindings: parseBindings("shift+enter", "ctrl+j"),
  284. },
  285. {
  286. Name: MessagesPageUpCommand,
  287. Description: "page up",
  288. Keybindings: parseBindings("pgup"),
  289. },
  290. {
  291. Name: MessagesPageDownCommand,
  292. Description: "page down",
  293. Keybindings: parseBindings("pgdown"),
  294. },
  295. {
  296. Name: MessagesHalfPageUpCommand,
  297. Description: "half page up",
  298. Keybindings: parseBindings("ctrl+alt+u"),
  299. },
  300. {
  301. Name: MessagesHalfPageDownCommand,
  302. Description: "half page down",
  303. Keybindings: parseBindings("ctrl+alt+d"),
  304. },
  305. {
  306. Name: MessagesPreviousCommand,
  307. Description: "previous message",
  308. Keybindings: parseBindings("ctrl+up"),
  309. },
  310. {
  311. Name: MessagesNextCommand,
  312. Description: "next message",
  313. Keybindings: parseBindings("ctrl+down"),
  314. },
  315. {
  316. Name: MessagesFirstCommand,
  317. Description: "first message",
  318. Keybindings: parseBindings("ctrl+g"),
  319. },
  320. {
  321. Name: MessagesLastCommand,
  322. Description: "last message",
  323. Keybindings: parseBindings("ctrl+alt+g"),
  324. },
  325. {
  326. Name: MessagesLayoutToggleCommand,
  327. Description: "toggle layout",
  328. Keybindings: parseBindings("<leader>p"),
  329. },
  330. {
  331. Name: MessagesCopyCommand,
  332. Description: "copy message",
  333. Keybindings: parseBindings("<leader>y"),
  334. },
  335. {
  336. Name: MessagesUndoCommand,
  337. Description: "undo last message",
  338. Keybindings: parseBindings("<leader>u"),
  339. Trigger: []string{"undo"},
  340. },
  341. {
  342. Name: MessagesRedoCommand,
  343. Description: "redo message",
  344. Keybindings: parseBindings("<leader>r"),
  345. Trigger: []string{"redo"},
  346. },
  347. {
  348. Name: AppExitCommand,
  349. Description: "exit the app",
  350. Keybindings: parseBindings("ctrl+c", "<leader>q"),
  351. Trigger: []string{"exit", "quit", "q"},
  352. },
  353. }
  354. registry := make(CommandRegistry)
  355. keybinds := map[string]string{}
  356. marshalled, _ := json.Marshal(config.Keybinds)
  357. json.Unmarshal(marshalled, &keybinds)
  358. for _, command := range defaults {
  359. // Remove share/unshare commands if sharing is disabled
  360. if config.Share == opencode.ConfigShareDisabled &&
  361. (command.Name == SessionShareCommand || command.Name == SessionUnshareCommand) {
  362. slog.Info("Removing share/unshare commands")
  363. continue
  364. }
  365. if keybind, ok := keybinds[string(command.Name)]; ok && keybind != "" {
  366. command.Keybindings = parseBindings(keybind)
  367. }
  368. registry[command.Name] = command
  369. }
  370. slog.Info("Loaded commands", "commands", registry)
  371. return registry
  372. }