command.go 11 KB

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