config-v2.test.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. /** @jsxImportSource @opentui/solid */
  2. import { testRender } from "@opentui/solid"
  3. import { expect, test } from "bun:test"
  4. import { Schema } from "effect"
  5. import { resolve, ConfigProvider, Info, useConfig, type Interface } from "../src/config"
  6. import { settings } from "../src/component/dialog-config"
  7. import { TuiKeybind } from "../src/config/keybind"
  8. import { CommandMap, Definitions } from "../src/config/v1/keybind"
  9. const decodeInfo = Schema.decodeUnknownSync(Info)
  10. test("validates mini replay settings", () => {
  11. expect(decodeInfo({ mini: { replay: false, replay_limit: 50 } })).toEqual({
  12. mini: { replay: false, replay_limit: 50 },
  13. })
  14. expect(() => decodeInfo({ mini: { replay_limit: 0 } })).toThrow()
  15. expect(() => decodeInfo({ mini: { replay_limit: 1.5 } })).toThrow()
  16. })
  17. test("validates the session tabs setting", () => {
  18. const decode = Schema.decodeUnknownSync(Info)
  19. expect(decode({ tabs: { enabled: true, layout: "vertical" } })).toEqual({
  20. tabs: { enabled: true, layout: "vertical" },
  21. })
  22. expect(() => decode({ tabs: { layout: true } })).toThrow()
  23. expect(() => decode({ tabs: { enabled: "on" } })).toThrow()
  24. expect(decode({ prompt: { image_preview: true } })).toEqual({ prompt: { image_preview: true } })
  25. expect(decode({ session: { image_preview: true } })).toEqual({ session: { image_preview: true } })
  26. expect(decode({ session: { new_location: "inherit" } })).toEqual({ session: { new_location: "inherit" } })
  27. expect(() => decode({ session: { new_location: "current" } })).toThrow()
  28. })
  29. test("resolves nested config and keybind defaults", () => {
  30. const config = resolve(
  31. {
  32. keybinds: { leader: "ctrl+o" },
  33. leader: { timeout: 500 },
  34. scroll: { speed: 2, acceleration: true },
  35. diffs: { view: "split" },
  36. debug: { devtools: true },
  37. },
  38. { terminalSuspend: true },
  39. )
  40. expect(config.leader.timeout).toBe(500)
  41. expect(config.keybinds.get("leader")?.[0]?.key).toBe("ctrl+o")
  42. expect(config.scroll).toEqual({ speed: 2, acceleration: true })
  43. expect(config.diffs).toEqual({ view: "split" })
  44. expect(config.debug).toEqual({ devtools: true })
  45. expect(config.tabs).toEqual({ enabled: true, scope: "cwd", layout: "horizontal" })
  46. expect(config.session.new_location).toBe("launch")
  47. })
  48. test("shows resolved tab defaults in settings", () => {
  49. expect(settings.find((setting) => setting.path.join(".") === "tabs.enabled")?.default).toBe(true)
  50. expect(settings.find((setting) => setting.path.join(".") === "tabs.scope")?.default).toBe("cwd")
  51. expect(settings.find((setting) => setting.path.join(".") === "tabs.layout")?.default).toBe("horizontal")
  52. })
  53. test("shows the new session location default in settings", () => {
  54. expect(settings.find((setting) => setting.path.join(".") === "session.new_location")?.default).toBe("launch")
  55. })
  56. test("validates terminal copy behavior", () => {
  57. expect(decodeInfo({ terminal: { copy: "manual" } })).toEqual({ terminal: { copy: "manual" } })
  58. expect(decodeInfo({ terminal: { copy: "select" } })).toEqual({ terminal: { copy: "select" } })
  59. expect(() => decodeInfo({ terminal: { copy: "always" } })).toThrow()
  60. const setting = settings.find((setting) => setting.path.join(".") === "terminal.copy")
  61. expect(setting?.values).toEqual(["manual", "select"])
  62. expect(setting?.default).toBe(process.platform === "win32" ? "manual" : "select")
  63. })
  64. test("uses command IDs as keybind keys", () => {
  65. const config = resolve({ keybinds: { "session.list": "ctrl+l" } }, { terminalSuspend: true })
  66. expect(config.keybinds.get("session.list")).toMatchObject([{ key: "ctrl+l" }])
  67. expect(TuiKeybind.unknownKeys({ session_list: "ctrl+l" })).toEqual(["session_list"])
  68. expect(
  69. Object.keys(TuiKeybind.Definitions)
  70. .filter((key) => key !== "leader")
  71. .every((key) => key.includes(".")),
  72. ).toBe(true)
  73. })
  74. test("preserves current navigation defaults", () => {
  75. const config = resolve({}, { terminalSuspend: true })
  76. expect(config.keybinds.get("open.menu")).toMatchObject([{ key: "ctrl+o" }])
  77. expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,alt+down" }])
  78. expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,alt+up" }])
  79. expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "alt+shift+down" }])
  80. expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "alt+shift+up" }])
  81. expect(config.keybinds.get("session.tab.reopen")).toMatchObject([{ key: "ctrl+shift+t" }])
  82. expect(config.keybinds.get("session.tab.select.10")).toMatchObject([{ key: "<leader>0,ctrl+0" }])
  83. expect(config.keybinds.get("session.message.next")).toEqual([])
  84. expect(config.keybinds.get("session.message.previous")).toEqual([])
  85. expect(config.keybinds.get("session.message.user.next")).toEqual([])
  86. expect(config.keybinds.get("session.message.user.previous")).toEqual([])
  87. expect(config.keybinds.get("input.buffer.home")).toEqual([])
  88. expect(config.keybinds.get("input.buffer.end")).toEqual([])
  89. expect(config.keybinds.get("prompt.images.view")).toMatchObject([{ key: "<leader>i" }])
  90. })
  91. test("preserves migrated v1 keybind defaults", () => {
  92. const pairs = [
  93. ["app.exit", "app_exit"],
  94. ["prompt.paste", "input_paste"],
  95. ["prompt.queue", "prompt_queue"],
  96. ["session.delete", "session_delete"],
  97. ["session.list", "session_list"],
  98. ["agent.list", "agent_list"],
  99. ] as const
  100. pairs.forEach(([command, name]) => {
  101. expect(CommandMap[name]).toBe(command)
  102. expect(TuiKeybind.Definitions[command].default).toEqual(Definitions[name].default)
  103. })
  104. })
  105. test("accepts every v2-only named command ID", () => {
  106. const commands = [
  107. "server.pair",
  108. "session.toggle.exploration_grouping",
  109. "composer.subagent.up",
  110. "composer.subagent.down",
  111. "composer.subagent.select",
  112. "composer.subagent.interrupt",
  113. "composer.shell.up",
  114. "composer.shell.down",
  115. "composer.shell.kill",
  116. "diff.down",
  117. "diff.up",
  118. "diff.page.down",
  119. "diff.page.up",
  120. "diff.mark_reviewed",
  121. "opencode.settings",
  122. "service.restart",
  123. "permission.mode",
  124. "session.cd",
  125. "app.scrap",
  126. ]
  127. const config = resolve(
  128. decodeInfo({ keybinds: Object.fromEntries(commands.map((command) => [command, "ctrl+alt+z"])) }),
  129. { terminalSuspend: true },
  130. )
  131. commands.forEach((command) => expect(config.keybinds.get(command)).toMatchObject([{ key: "ctrl+alt+z" }]))
  132. })
  133. test("centralizes named command defaults and resolves explicit none", () => {
  134. const defaults = {
  135. "composer.subagent.up": "up",
  136. "composer.subagent.down": "down",
  137. "composer.subagent.select": "return",
  138. "composer.subagent.interrupt": "ctrl+d",
  139. "composer.shell.up": "up",
  140. "composer.shell.down": "down",
  141. "composer.shell.kill": "ctrl+d",
  142. "diff.down": "j,down",
  143. "diff.up": "k,up",
  144. "diff.page.down": "pagedown,ctrl+f",
  145. "diff.page.up": "pageup,ctrl+b",
  146. "diff.mark_reviewed": "m",
  147. }
  148. const config = resolve({}, { terminalSuspend: true })
  149. Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }]))
  150. const disabled = resolve(
  151. decodeInfo({ keybinds: Object.fromEntries(Object.keys(defaults).map((command) => [command, "none"])) }),
  152. { terminalSuspend: true },
  153. )
  154. Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([]))
  155. })
  156. test("rejects orphaned keybind definitions", () => {
  157. expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} })
  158. })
  159. test("uses ctrl+z for input undo when terminal suspend is unavailable", () => {
  160. const config = resolve({}, { terminalSuspend: false })
  161. expect(config.keybinds.has("terminal.suspend")).toBe(false)
  162. expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
  163. const overridden = resolve(
  164. { keybinds: { "terminal.suspend": "ctrl+s", "input.undo": "ctrl+u" } },
  165. { terminalSuspend: false },
  166. )
  167. expect(overridden.keybinds.has("terminal.suspend")).toBe(false)
  168. expect(overridden.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
  169. })
  170. test("keeps turn token usage inside developer tools", () => {
  171. expect(settings.find((setting) => setting.path.join(".") === "debug.devtools")?.title).toBe("Developer tools")
  172. expect(settings.some((setting) => setting.path.join(".") === "debug.turn_tokens")).toBe(false)
  173. })
  174. test("provides config and its host interface", async () => {
  175. const config = resolve({}, { terminalSuspend: true })
  176. let current = {}
  177. const service: Interface = {
  178. get: async () => current,
  179. update: async (update) => {
  180. const draft: Record<string, any> = { ...current }
  181. update(draft)
  182. current = draft
  183. return draft
  184. },
  185. }
  186. let context: ReturnType<typeof useConfig> | undefined
  187. function Consumer() {
  188. context = useConfig()
  189. return <text>{`${context.data.mouse ? "mouse" : "none"} ${context.data.keybinds.get("leader")?.[0]?.key}`}</text>
  190. }
  191. const app = await testRender(() => (
  192. <ConfigProvider config={config} service={service}>
  193. <Consumer />
  194. </ConfigProvider>
  195. ))
  196. try {
  197. await app.renderOnce()
  198. expect(app.captureCharFrame()).toContain("mouse ctrl+x")
  199. if (!context) throw new Error("Config context was not provided")
  200. await context.update((draft) => {
  201. draft.mouse = false
  202. draft.keybinds = { leader: "ctrl+o" }
  203. })
  204. await app.renderOnce()
  205. expect(app.captureCharFrame()).toContain("none ctrl+o")
  206. } finally {
  207. app.renderer.destroy()
  208. }
  209. })