command.go 12 KB

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