command.go 11 KB

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