command.go 9.0 KB

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