keymap.tsx 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. import { InputRenderable, TextareaRenderable, type CliRenderer, type KeyEvent, type Renderable } from "@opentui/core"
  2. import {
  3. registerBackspacePopsPendingSequence,
  4. registerBaseLayoutFallback,
  5. registerCommaBindings,
  6. registerEscapeClearsPendingSequence,
  7. registerManagedTextareaLayer,
  8. registerTimedLeader,
  9. } from "@opentui/keymap/addons/opentui"
  10. import { stringifyKeyStroke, type Binding } from "@opentui/keymap"
  11. import {
  12. formatCommandBindings as formatCommandBindingsExtra,
  13. formatKeySequence as formatKeySequenceExtra,
  14. } from "@opentui/keymap/extras"
  15. import { KeymapProvider, useKeymap, useKeymapSelector, useBindings } from "@opentui/keymap/solid"
  16. import { createMemo, type Accessor } from "solid-js"
  17. import { useTuiConfig } from "./config"
  18. import { TuiKeybind } from "./config/keybind"
  19. export const LEADER_TOKEN = "leader"
  20. export const KIRINCODE_BASE_MODE = "base"
  21. export const COMMAND_PALETTE_COMMAND = "command.palette.show"
  22. const KIRINCODE_MODE_KEY = "opencode.mode"
  23. export const OpencodeKeymapProvider = KeymapProvider
  24. export const useOpencodeKeymap = useKeymap
  25. export { useBindings, useKeymapSelector }
  26. export type OpenTuiKeymap = ReturnType<typeof useKeymap>
  27. type OpencodeModeStack = ReturnType<typeof createOpencodeModeStack>
  28. type CommandSlashEntry = {
  29. display: string
  30. description?: string
  31. aliases?: string[]
  32. onSelect: () => void
  33. }
  34. type Command = ReturnType<OpenTuiKeymap["getCommands"]>[number]
  35. type BindingLookup = {
  36. get(command: string): readonly Binding<Renderable, KeyEvent>[]
  37. gather(name: string, commands: readonly string[]): readonly Binding<Renderable, KeyEvent>[]
  38. }
  39. type FormatConfig = { keybinds: BindingLookup }
  40. type ResolvedKeymapConfig = FormatConfig & { leader_timeout: number }
  41. const modeStacks = new WeakMap<OpenTuiKeymap, OpencodeModeStack>()
  42. function isVisiblePaletteCommand(command: Command) {
  43. return command.hidden !== true && command.name !== COMMAND_PALETTE_COMMAND
  44. }
  45. export function createOpencodeModeStack(keymap: OpenTuiKeymap) {
  46. keymap.setData(KIRINCODE_MODE_KEY, KIRINCODE_BASE_MODE)
  47. const offFields = keymap.registerLayerFields({
  48. mode(value, ctx) {
  49. ctx.require(KIRINCODE_MODE_KEY, value)
  50. },
  51. })
  52. const stack: { id: symbol; mode: string }[] = []
  53. let disposed = false
  54. const update = () => {
  55. keymap.setData(KIRINCODE_MODE_KEY, stack.at(-1)?.mode ?? KIRINCODE_BASE_MODE)
  56. }
  57. const stackApi = {
  58. current() {
  59. return stack.at(-1)?.mode ?? KIRINCODE_BASE_MODE
  60. },
  61. push(mode: string) {
  62. if (disposed) return () => {}
  63. const id = Symbol(mode)
  64. let active = true
  65. stack.push({ id, mode })
  66. update()
  67. return () => {
  68. if (!active) return
  69. active = false
  70. const index = stack.findIndex((item) => item.id === id)
  71. if (index !== -1) stack.splice(index, 1)
  72. update()
  73. }
  74. },
  75. dispose() {
  76. if (disposed) return
  77. disposed = true
  78. stack.length = 0
  79. offFields()
  80. keymap.setData(KIRINCODE_MODE_KEY, undefined)
  81. modeStacks.delete(keymap)
  82. },
  83. }
  84. modeStacks.set(keymap, stackApi)
  85. return stackApi
  86. }
  87. export function useOpencodeModeStack() {
  88. return getOpencodeModeStack(useOpencodeKeymap())
  89. }
  90. export function getOpencodeModeStack(keymap: OpenTuiKeymap) {
  91. const value = modeStacks.get(keymap)
  92. if (!value) throw new Error("Opencode mode stack is not registered for this keymap")
  93. return value
  94. }
  95. const KEY_ALIASES = {
  96. enter: "return",
  97. esc: "escape",
  98. pgdown: "pagedown",
  99. pgup: "pageup",
  100. } as const
  101. function expandKeyAliases(input: string) {
  102. const result = Object.entries(KEY_ALIASES).reduce(
  103. (acc, [alias, key]) => acc.replace(new RegExp(`(^|[+,\\s>])${alias}(?=$|[+,\\s<])`, "gi"), `$1${key}`),
  104. input,
  105. )
  106. if (result === input) return
  107. return result
  108. }
  109. function registerKeyAliases(keymap: OpenTuiKeymap) {
  110. return keymap.appendBindingExpander((ctx) => {
  111. const key = expandKeyAliases(ctx.input)
  112. if (!key) return
  113. return [{ key, displays: ctx.displays }]
  114. })
  115. }
  116. const inputCommands = [
  117. "input.move.left",
  118. "input.move.right",
  119. "input.move.up",
  120. "input.move.down",
  121. "input.select.left",
  122. "input.select.right",
  123. "input.select.up",
  124. "input.select.down",
  125. "input.line.home",
  126. "input.line.end",
  127. "input.select.line.home",
  128. "input.select.line.end",
  129. "input.visual.line.home",
  130. "input.visual.line.end",
  131. "input.select.visual.line.home",
  132. "input.select.visual.line.end",
  133. "input.buffer.home",
  134. "input.buffer.end",
  135. "input.select.buffer.home",
  136. "input.select.buffer.end",
  137. "input.delete.line",
  138. "input.delete.to.line.end",
  139. "input.delete.to.line.start",
  140. "input.backspace",
  141. "input.delete",
  142. "input.newline",
  143. "input.undo",
  144. "input.redo",
  145. "input.word.forward",
  146. "input.word.backward",
  147. "input.select.word.forward",
  148. "input.select.word.backward",
  149. "input.delete.word.forward",
  150. "input.delete.word.backward",
  151. "input.select.all",
  152. "input.submit",
  153. ] as const
  154. function hasManagedTextareaFocus(renderer: CliRenderer) {
  155. const editor = renderer.currentFocusedEditor
  156. return editor instanceof TextareaRenderable && !(editor instanceof InputRenderable)
  157. }
  158. function leaderDisplay(config: FormatConfig) {
  159. const key = config.keybinds.get(LEADER_TOKEN)?.[0]?.key
  160. if (!key) return TuiKeybind.LeaderDefault
  161. return typeof key === "string" ? key : stringifyKeyStroke(key)
  162. }
  163. function leaderKey(config: FormatConfig) {
  164. return config.keybinds.get(LEADER_TOKEN)?.[0]?.key
  165. }
  166. function formatOptions(config: FormatConfig) {
  167. return {
  168. tokenDisplay: {
  169. [LEADER_TOKEN]: leaderDisplay(config),
  170. },
  171. keyNameAliases: {
  172. pageup: "pgup",
  173. pagedown: "pgdn",
  174. delete: "del",
  175. },
  176. modifierAliases: {
  177. meta: "alt",
  178. },
  179. } as const
  180. }
  181. export function formatKeySequence(parts: Parameters<typeof formatKeySequenceExtra>[0], config: FormatConfig) {
  182. return formatKeySequenceExtra(parts, formatOptions(config))
  183. }
  184. export function formatKeyBindings(bindings: Parameters<typeof formatCommandBindingsExtra>[0], config: FormatConfig) {
  185. return formatCommandBindingsExtra(bindings, formatOptions(config))
  186. }
  187. export function registerOpencodeKeymap(keymap: OpenTuiKeymap, renderer: CliRenderer, config: ResolvedKeymapConfig) {
  188. const modeStack = createOpencodeModeStack(keymap)
  189. const offCommaBindings = registerCommaBindings(keymap)
  190. const offAliasExpander = registerKeyAliases(keymap)
  191. const offBaseLayout = registerBaseLayoutFallback(keymap)
  192. const leader = leaderKey(config)
  193. const offLeader = leader
  194. ? registerTimedLeader(keymap, {
  195. trigger: leader,
  196. name: LEADER_TOKEN,
  197. timeoutMs: config.leader_timeout,
  198. })
  199. : () => {}
  200. const offEscape = registerEscapeClearsPendingSequence(keymap)
  201. const offBackspace = registerBackspacePopsPendingSequence(keymap)
  202. const offInputBindings = registerManagedTextareaLayer(keymap, renderer, {
  203. enabled: () => hasManagedTextareaFocus(renderer),
  204. bindings: config.keybinds.gather("input", inputCommands),
  205. })
  206. return () => {
  207. offInputBindings()
  208. offBackspace()
  209. offEscape()
  210. offLeader()
  211. offAliasExpander()
  212. offBaseLayout()
  213. offCommaBindings()
  214. modeStack.dispose()
  215. }
  216. }
  217. export function useLeaderActive(): Accessor<boolean> {
  218. return useKeymapSelector((keymap: OpenTuiKeymap) => keymap.getPendingSequence()[0]?.tokenName === LEADER_TOKEN)
  219. }
  220. export function useCommandShortcut(command: string): Accessor<string> {
  221. const config = useTuiConfig()
  222. return useKeymapSelector((keymap: OpenTuiKeymap) =>
  223. formatKeySequence(
  224. keymap.getCommandBindings({ visibility: "registered", commands: [command] }).get(command)?.[0]?.sequence,
  225. config,
  226. ),
  227. )
  228. }
  229. export function useCommandSlashes(): Accessor<readonly CommandSlashEntry[]> {
  230. const keymap = useOpencodeKeymap()
  231. const entries = useKeymapSelector((keymap: OpenTuiKeymap) =>
  232. keymap.getCommandEntries({
  233. visibility: "reachable",
  234. namespace: "palette",
  235. filter: isVisiblePaletteCommand,
  236. }),
  237. )
  238. return createMemo<CommandSlashEntry[]>(() =>
  239. entries().flatMap((entry) => {
  240. const slashName = entry.command.slashName
  241. if (typeof slashName !== "string" || !slashName) return []
  242. const slashAliases = entry.command.slashAliases
  243. return {
  244. display: `/${slashName}`,
  245. description:
  246. typeof entry.command.desc === "string"
  247. ? entry.command.desc
  248. : typeof entry.command.title === "string"
  249. ? entry.command.title
  250. : undefined,
  251. aliases: Array.isArray(slashAliases)
  252. ? slashAliases.filter((alias): alias is string => typeof alias === "string").map((alias) => `/${alias}`)
  253. : undefined,
  254. onSelect: () => keymap.dispatchCommand(entry.command.name),
  255. }
  256. }),
  257. )
  258. }