keymap.test.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. /** @jsxImportSource @opentui/solid */
  2. import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
  3. import { createBindingLookup } from "@opentui/keymap/extras"
  4. import { testRender, useRenderer } from "@opentui/solid"
  5. import { expect, test } from "bun:test"
  6. import { onCleanup } from "solid-js"
  7. import { TuiKeybind } from "../src/config/v1/keybind"
  8. import {
  9. formatKeySequence,
  10. getOpencodeModeStack,
  11. OPENCODE_BASE_MODE,
  12. OpencodeKeymapProvider,
  13. registerOpencodeKeymap,
  14. } from "../src/keymap"
  15. function createResolvedKeymapConfig(input: TuiKeybind.KeybindOverrides = {}) {
  16. const keybinds = TuiKeybind.parse(input)
  17. return {
  18. keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
  19. commandMap: TuiKeybind.CommandMap,
  20. bindingDefaults: TuiKeybind.bindingDefaults(),
  21. }),
  22. leader_timeout: 2000,
  23. }
  24. }
  25. test("legacy page key aliases compile as page keys", async () => {
  26. const sequences: Record<string, string[][]> = {}
  27. function Harness() {
  28. const renderer = useRenderer()
  29. const keymap = createDefaultOpenTuiKeymap(renderer)
  30. const config = createResolvedKeymapConfig({
  31. messages_page_up: "pgup",
  32. messages_page_down: "pgdown",
  33. })
  34. const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
  35. const offLayer = keymap.registerLayer({
  36. bindings: config.keybinds.gather("session", ["session.page.up", "session.page.down"]),
  37. })
  38. const bindings = keymap.getCommandBindings({
  39. visibility: "registered",
  40. commands: ["session.page.up", "session.page.down"],
  41. })
  42. sequences.up =
  43. bindings.get("session.page.up")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
  44. sequences.down =
  45. bindings.get("session.page.down")?.map((binding) => binding.sequence.map((part) => part.stroke.name)) ?? []
  46. onCleanup(() => {
  47. offLayer()
  48. offKeymap()
  49. })
  50. return (
  51. <OpencodeKeymapProvider keymap={keymap}>
  52. <box />
  53. </OpencodeKeymapProvider>
  54. )
  55. }
  56. const app = await testRender(() => <Harness />)
  57. try {
  58. expect(sequences).toEqual({
  59. up: [["pageup"]],
  60. down: [["pagedown"]],
  61. })
  62. } finally {
  63. app.renderer.destroy()
  64. }
  65. })
  66. test("formats navigation keys as arrows", async () => {
  67. const shortcuts: Record<string, string> = {}
  68. function Harness() {
  69. const renderer = useRenderer()
  70. const keymap = createDefaultOpenTuiKeymap(renderer)
  71. const config = createResolvedKeymapConfig()
  72. const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
  73. const commands = ["session.parent", "session.child.first", "session.child.previous", "session.child.next"]
  74. const offLayer = keymap.registerLayer({
  75. bindings: config.keybinds.gather("test.arrows", commands),
  76. })
  77. const bindings = keymap.getCommandBindings({ visibility: "registered", commands })
  78. commands.forEach((command) => {
  79. shortcuts[command] = formatKeySequence(bindings.get(command)?.[0]?.sequence, config)
  80. })
  81. onCleanup(() => {
  82. offLayer()
  83. offKeymap()
  84. })
  85. return (
  86. <OpencodeKeymapProvider keymap={keymap}>
  87. <box />
  88. </OpencodeKeymapProvider>
  89. )
  90. }
  91. const app = await testRender(() => <Harness />)
  92. try {
  93. expect(shortcuts).toEqual({
  94. "session.parent": "↑",
  95. "session.child.first": "↓",
  96. "session.child.previous": "←",
  97. "session.child.next": "→",
  98. })
  99. } finally {
  100. app.renderer.destroy()
  101. }
  102. })
  103. test("mode-less bindings stay active when opencode mode changes", async () => {
  104. const counts: Record<string, Record<string, number>> = {}
  105. function Harness() {
  106. const renderer = useRenderer()
  107. const keymap = createDefaultOpenTuiKeymap(renderer)
  108. const config = createResolvedKeymapConfig()
  109. const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
  110. const offGlobal = keymap.registerLayer({
  111. commands: [
  112. { name: "session.list", run() {} },
  113. { name: "session.new", run() {} },
  114. { name: "session.page.up", run() {} },
  115. { name: "session.first", run() {} },
  116. ],
  117. bindings: config.keybinds.gather("test.global", [
  118. "session.list",
  119. "session.new",
  120. "session.page.up",
  121. "session.first",
  122. ]),
  123. })
  124. const offBase = keymap.registerLayer({
  125. mode: OPENCODE_BASE_MODE,
  126. commands: [{ name: "model.list", run() {} }],
  127. bindings: config.keybinds.gather("test.base", ["model.list"]),
  128. })
  129. const activeCounts = () =>
  130. Object.fromEntries(
  131. Array.from(
  132. keymap.getCommandBindings({
  133. visibility: "active",
  134. commands: ["session.list", "session.new", "session.page.up", "session.first", "model.list"],
  135. }),
  136. ([command, bindings]) => [command, bindings.length],
  137. ),
  138. )
  139. counts.base = activeCounts()
  140. const popQuestion = getOpencodeModeStack(keymap).push("question")
  141. counts.question = activeCounts()
  142. popQuestion()
  143. const popAutocomplete = getOpencodeModeStack(keymap).push("autocomplete")
  144. counts.autocomplete = activeCounts()
  145. popAutocomplete()
  146. onCleanup(() => {
  147. offBase()
  148. offGlobal()
  149. offKeymap()
  150. })
  151. return (
  152. <OpencodeKeymapProvider keymap={keymap}>
  153. <box />
  154. </OpencodeKeymapProvider>
  155. )
  156. }
  157. const app = await testRender(() => <Harness />)
  158. try {
  159. expect(counts).toEqual({
  160. base: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 1 },
  161. question: { "session.list": 1, "session.new": 1, "session.page.up": 2, "session.first": 2, "model.list": 0 },
  162. autocomplete: {
  163. "session.list": 1,
  164. "session.new": 1,
  165. "session.page.up": 2,
  166. "session.first": 2,
  167. "model.list": 0,
  168. },
  169. })
  170. } finally {
  171. app.renderer.destroy()
  172. }
  173. })