config.test.tsx 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /** @jsxImportSource @opentui/solid */
  2. import { testRender } from "@opentui/solid"
  3. import { expect, test } from "bun:test"
  4. import { Schema } from "effect"
  5. import {
  6. AttentionSoundName,
  7. Info,
  8. LeaderTimeoutDefault,
  9. PluginSpec,
  10. resolve,
  11. TuiConfigProvider,
  12. type Info as TuiConfigInfo,
  13. useTuiConfig,
  14. } from "../src/config/v1"
  15. const decodeInfo = Schema.decodeUnknownSync(Info)
  16. const decodePlugin = Schema.decodeUnknownSync(PluginSpec)
  17. test("defines package-owned plugin specs and attention sound names", () => {
  18. expect(decodePlugin("example-plugin")).toBe("example-plugin")
  19. expect(decodePlugin(["example-plugin", { enabled: true }])).toEqual(["example-plugin", { enabled: true }])
  20. expect(() => decodePlugin(["example-plugin"])).toThrow()
  21. expect(AttentionSoundName.literals).toEqual(["default", "question", "permission", "error", "done", "subagent_done"])
  22. })
  23. test("validates config constraints", () => {
  24. expect(
  25. decodeInfo({
  26. leader_timeout: 250,
  27. attention: { volume: 1, sounds: { done: "done.wav" } },
  28. prompt: { max_height: 10, max_width: "auto" },
  29. scroll_speed: 0.001,
  30. diff_style: "stacked",
  31. plugin: ["example-plugin"],
  32. }),
  33. ).toMatchObject({ leader_timeout: 250, attention: { volume: 1 }, diff_style: "stacked" })
  34. expect(() => decodeInfo({ leader_timeout: 0 })).toThrow()
  35. expect(() => decodeInfo({ attention: { volume: 1.1 } })).toThrow()
  36. expect(() => decodeInfo({ prompt: { max_width: 0 } })).toThrow()
  37. expect(() => decodeInfo({ scroll_speed: 0 })).toThrow()
  38. expect(decodeInfo({ attention: { sounds: { unknown: "sound.wav" } } })).toEqual({ attention: { sounds: {} } })
  39. })
  40. test("resolves host-neutral defaults", () => {
  41. const config = resolve({}, { terminalSuspend: true })
  42. expect(config.attention).toEqual({
  43. enabled: false,
  44. notifications: true,
  45. sound: true,
  46. volume: 0.4,
  47. sound_pack: "opencode.default",
  48. sounds: {},
  49. })
  50. expect(config.leader_timeout).toBe(LeaderTimeoutDefault)
  51. expect(config.mouse).toBe(true)
  52. expect(config.keybinds.has("terminal.suspend")).toBe(true)
  53. expect(config.keybinds.has("session.list")).toBe(true)
  54. })
  55. test("resolves overrides without mutating input", () => {
  56. const input: TuiConfigInfo = {
  57. theme: "custom",
  58. mouse: false,
  59. leader_timeout: 750,
  60. attention: {
  61. enabled: true,
  62. notifications: false,
  63. sound: false,
  64. volume: 0.8,
  65. sound_pack: "custom.pack",
  66. sounds: { question: "/sounds/question.wav" },
  67. },
  68. keybinds: { session_list: "ctrl+l" },
  69. }
  70. const config = resolve(input, { terminalSuspend: true })
  71. expect(config).toMatchObject({ theme: "custom", mouse: false, leader_timeout: 750, attention: input.attention })
  72. expect(config.keybinds.get("session.list")).toHaveLength(1)
  73. expect(input.keybinds).toEqual({ session_list: "ctrl+l" })
  74. })
  75. test("resolves a session move keybind", () => {
  76. const config = resolve({ keybinds: { session_move: "ctrl+o" } }, { terminalSuspend: true })
  77. expect(config.keybinds.get("session.move")).toMatchObject([{ key: "ctrl+o" }])
  78. })
  79. test("resolves message navigation defaults", () => {
  80. const config = resolve({}, { terminalSuspend: true })
  81. expect(config.keybinds.get("session.first")).toMatchObject([{ key: "ctrl+g,home,alt+home" }])
  82. expect(config.keybinds.get("session.message.previous")).toMatchObject([{ key: "alt+up" }])
  83. expect(config.keybinds.get("session.message.next")).toMatchObject([{ key: "alt+down" }])
  84. expect(config.keybinds.get("session.message.user.previous")).toMatchObject([{ key: "alt+shift+up" }])
  85. expect(config.keybinds.get("session.message.user.next")).toMatchObject([{ key: "alt+shift+down" }])
  86. expect(config.keybinds.get("session.messages_last_user")).toMatchObject([{ key: "alt+end" }])
  87. })
  88. test("opens the subagent picker with down", () => {
  89. const config = resolve({}, { terminalSuspend: true })
  90. expect(config.keybinds.get("session.child.first")).toMatchObject([{ key: "down" }])
  91. })
  92. test("navigates session tabs with leader arrows", () => {
  93. const config = resolve({}, { terminalSuspend: true })
  94. expect(config.keybinds.get("session.tab.next")).toMatchObject([{ key: "ctrl+tab,<leader>right" }])
  95. expect(config.keybinds.get("session.tab.previous")).toMatchObject([{ key: "ctrl+shift+tab,<leader>left" }])
  96. expect(config.keybinds.get("session.tab.history.back")).toMatchObject([{ key: "ctrl+o" }])
  97. expect(config.keybinds.get("session.tab.history.forward")).toMatchObject([{ key: "ctrl+i" }])
  98. expect(config.keybinds.get("session.tab.next_unread")).toMatchObject([{ key: "<leader>down" }])
  99. expect(config.keybinds.get("session.tab.previous_unread")).toMatchObject([{ key: "<leader>up" }])
  100. })
  101. test("preserves pinned session bindings alongside tab bindings", () => {
  102. const config = resolve({}, { terminalSuspend: true })
  103. expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
  104. expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])
  105. expect(config.keybinds.get("session.tab.select.1")).toMatchObject([{ key: "<leader>1,ctrl+1" }])
  106. })
  107. test("disables suspend and assigns ctrl+z to undo when unsupported", () => {
  108. const config = resolve({}, { terminalSuspend: false })
  109. expect(config.keybinds.has("terminal.suspend")).toBe(false)
  110. expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+z,ctrl+-,super+z" }])
  111. })
  112. test("preserves an explicit undo binding when suspend is unsupported", () => {
  113. const config = resolve({ keybinds: { input_undo: "ctrl+u", terminal_suspend: "ctrl+s" } }, { terminalSuspend: false })
  114. expect(config.keybinds.has("terminal.suspend")).toBe(false)
  115. expect(config.keybinds.get("input.undo")).toHaveLength(1)
  116. expect(config.keybinds.get("input.undo")).toMatchObject([{ key: "ctrl+u" }])
  117. })
  118. test("provides resolved config through Solid context", async () => {
  119. const config = resolve({ theme: "custom" }, { terminalSuspend: true })
  120. function Consumer() {
  121. const value = useTuiConfig()
  122. return <text>{`${value.theme} ${value.mouse} ${value.leader_timeout}`}</text>
  123. }
  124. const app = await testRender(() => (
  125. <TuiConfigProvider config={config}>
  126. <Consumer />
  127. </TuiConfigProvider>
  128. ))
  129. try {
  130. await app.renderOnce()
  131. expect(app.captureCharFrame()).toContain(`custom true ${LeaderTimeoutDefault}`)
  132. } finally {
  133. app.renderer.destroy()
  134. }
  135. })
  136. test("requires the config provider", () => {
  137. expect(() => useTuiConfig()).toThrow("TuiConfigProvider is missing")
  138. })