settings.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import { createStore, reconcile } from "solid-js/store"
  2. import { createEffect, createMemo } from "solid-js"
  3. import { createSimpleContext } from "@kirincode-ai/ui/context"
  4. import { persisted } from "@/utils/persist"
  5. export interface NotificationSettings {
  6. agent: boolean
  7. permissions: boolean
  8. errors: boolean
  9. }
  10. export interface SoundSettings {
  11. agentEnabled: boolean
  12. agent: string
  13. permissionsEnabled: boolean
  14. permissions: string
  15. errorsEnabled: boolean
  16. errors: string
  17. }
  18. export interface Settings {
  19. general: {
  20. autoSave: boolean
  21. releaseNotes: boolean
  22. followup: "queue" | "steer"
  23. showFileTree: boolean
  24. showNavigation: boolean
  25. showSearch: boolean
  26. showStatus: boolean
  27. showTerminal: boolean
  28. showReasoningSummaries: boolean
  29. shellToolPartsExpanded: boolean
  30. editToolPartsExpanded: boolean
  31. showCustomAgents: boolean
  32. mobileTitlebarPosition: "top" | "bottom"
  33. newLayoutDesigns?: boolean
  34. }
  35. appearance: {
  36. fontSize: number
  37. mono: string
  38. sans: string
  39. terminal: string
  40. }
  41. keybinds: Record<string, string>
  42. permissions: {
  43. autoApprove: boolean
  44. }
  45. notifications: NotificationSettings
  46. sounds: SoundSettings
  47. }
  48. export const monoDefault = "System Mono"
  49. export const sansDefault = "System Sans"
  50. export const terminalDefault = "JetBrainsMono Nerd Font Mono"
  51. export const newLayoutDesignsDefault = import.meta.env.VITE_KIRINCODE_CHANNEL !== "prod"
  52. const monoFallback =
  53. 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  54. const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
  55. const terminalFallback =
  56. '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  57. const monoBase = monoFallback
  58. const sansBase = sansFallback
  59. const terminalBase = terminalFallback
  60. function input(font: string | undefined) {
  61. return font ?? ""
  62. }
  63. function family(font: string) {
  64. if (/^[\w-]+$/.test(font)) return font
  65. return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
  66. }
  67. function stack(font: string | undefined, base: string) {
  68. const value = font?.trim() ?? ""
  69. if (!value) return base
  70. return `${family(value)}, ${base}`
  71. }
  72. export function monoInput(font: string | undefined) {
  73. return input(font)
  74. }
  75. export function sansInput(font: string | undefined) {
  76. return input(font)
  77. }
  78. export function monoFontFamily(font: string | undefined) {
  79. return stack(font, monoBase)
  80. }
  81. export function sansFontFamily(font: string | undefined) {
  82. return stack(font, sansBase)
  83. }
  84. export function terminalInput(font: string | undefined) {
  85. return input(font)
  86. }
  87. export function terminalFontFamily(font: string | undefined) {
  88. return stack(font, terminalBase)
  89. }
  90. const defaultSettings: Settings = {
  91. general: {
  92. autoSave: true,
  93. releaseNotes: true,
  94. followup: "steer",
  95. showFileTree: false,
  96. showNavigation: false,
  97. showSearch: false,
  98. showStatus: false,
  99. showTerminal: false,
  100. showReasoningSummaries: false,
  101. shellToolPartsExpanded: false,
  102. editToolPartsExpanded: false,
  103. showCustomAgents: false,
  104. mobileTitlebarPosition: "top",
  105. },
  106. appearance: {
  107. fontSize: 14,
  108. mono: "",
  109. sans: "",
  110. terminal: "",
  111. },
  112. keybinds: {},
  113. permissions: {
  114. autoApprove: false,
  115. },
  116. notifications: {
  117. agent: true,
  118. permissions: true,
  119. errors: false,
  120. },
  121. sounds: {
  122. agentEnabled: true,
  123. agent: "staplebops-01",
  124. permissionsEnabled: true,
  125. permissions: "staplebops-02",
  126. errorsEnabled: true,
  127. errors: "nope-03",
  128. },
  129. }
  130. function withFallback<T>(read: () => T | undefined, fallback: T) {
  131. return createMemo(() => read() ?? fallback)
  132. }
  133. export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
  134. name: "Settings",
  135. gate: false,
  136. init: () => {
  137. const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
  138. const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
  139. const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
  140. const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
  141. const showCustomAgents = withFallback(
  142. () => store.general?.showCustomAgents,
  143. defaultSettings.general.showCustomAgents,
  144. )
  145. const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
  146. const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
  147. createEffect(() => {
  148. if (typeof document === "undefined") return
  149. const root = document.documentElement
  150. root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
  151. root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
  152. })
  153. createEffect(() => {
  154. if (store.general?.followup !== "queue") return
  155. setStore("general", "followup", "steer")
  156. })
  157. return {
  158. ready,
  159. get current() {
  160. return store
  161. },
  162. general: {
  163. autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
  164. setAutoSave(value: boolean) {
  165. setStore("general", "autoSave", value)
  166. },
  167. releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
  168. setReleaseNotes(value: boolean) {
  169. setStore("general", "releaseNotes", value)
  170. },
  171. followup: withFallback(
  172. () => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
  173. defaultSettings.general.followup,
  174. ),
  175. setFollowup(value: "queue" | "steer") {
  176. setStore("general", "followup", value === "queue" ? "steer" : value)
  177. },
  178. showFileTree,
  179. setShowFileTree(value: boolean) {
  180. setStore("general", "showFileTree", value)
  181. },
  182. showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
  183. setShowNavigation(value: boolean) {
  184. setStore("general", "showNavigation", value)
  185. },
  186. showSearch,
  187. setShowSearch(value: boolean) {
  188. setStore("general", "showSearch", value)
  189. },
  190. showStatus,
  191. setShowStatus(value: boolean) {
  192. setStore("general", "showStatus", value)
  193. },
  194. showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
  195. setShowTerminal(value: boolean) {
  196. setStore("general", "showTerminal", value)
  197. },
  198. showReasoningSummaries: withFallback(
  199. () => store.general?.showReasoningSummaries,
  200. defaultSettings.general.showReasoningSummaries,
  201. ),
  202. setShowReasoningSummaries(value: boolean) {
  203. setStore("general", "showReasoningSummaries", value)
  204. },
  205. shellToolPartsExpanded: withFallback(
  206. () => store.general?.shellToolPartsExpanded,
  207. defaultSettings.general.shellToolPartsExpanded,
  208. ),
  209. setShellToolPartsExpanded(value: boolean) {
  210. setStore("general", "shellToolPartsExpanded", value)
  211. },
  212. editToolPartsExpanded: withFallback(
  213. () => store.general?.editToolPartsExpanded,
  214. defaultSettings.general.editToolPartsExpanded,
  215. ),
  216. setEditToolPartsExpanded(value: boolean) {
  217. setStore("general", "editToolPartsExpanded", value)
  218. },
  219. showCustomAgents,
  220. setShowCustomAgents(value: boolean) {
  221. setStore("general", "showCustomAgents", value)
  222. },
  223. mobileTitlebarPosition: withFallback(
  224. () => store.general?.mobileTitlebarPosition,
  225. defaultSettings.general.mobileTitlebarPosition,
  226. ),
  227. setMobileTitlebarPosition(value: "top" | "bottom") {
  228. setStore("general", "mobileTitlebarPosition", value)
  229. },
  230. newLayoutDesigns,
  231. setNewLayoutDesigns(value: boolean) {
  232. setStore("general", "newLayoutDesigns", value)
  233. },
  234. },
  235. visibility: {
  236. fileTree: visible(showFileTree),
  237. search: visible(showSearch),
  238. status: visible(showStatus),
  239. customAgents: visible(showCustomAgents),
  240. },
  241. appearance: {
  242. fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
  243. setFontSize(value: number) {
  244. setStore("appearance", "fontSize", value)
  245. },
  246. font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
  247. setFont(value: string) {
  248. setStore("appearance", "mono", value.trim() ? value : "")
  249. },
  250. uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
  251. setUIFont(value: string) {
  252. setStore("appearance", "sans", value.trim() ? value : "")
  253. },
  254. terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
  255. setTerminalFont(value: string) {
  256. setStore("appearance", "terminal", value.trim() ? value : "")
  257. },
  258. },
  259. keybinds: {
  260. get: (action: string) => store.keybinds?.[action],
  261. set(action: string, keybind: string) {
  262. setStore("keybinds", action, keybind)
  263. },
  264. reset(action: string) {
  265. setStore("keybinds", (current) => {
  266. if (!Object.prototype.hasOwnProperty.call(current, action)) return current
  267. const next = { ...current }
  268. delete next[action]
  269. return next
  270. })
  271. },
  272. resetAll() {
  273. setStore("keybinds", reconcile({}))
  274. },
  275. },
  276. permissions: {
  277. autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
  278. setAutoApprove(value: boolean) {
  279. setStore("permissions", "autoApprove", value)
  280. },
  281. },
  282. notifications: {
  283. agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
  284. setAgent(value: boolean) {
  285. setStore("notifications", "agent", value)
  286. },
  287. permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
  288. setPermissions(value: boolean) {
  289. setStore("notifications", "permissions", value)
  290. },
  291. errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
  292. setErrors(value: boolean) {
  293. setStore("notifications", "errors", value)
  294. },
  295. },
  296. sounds: {
  297. agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
  298. setAgentEnabled(value: boolean) {
  299. setStore("sounds", "agentEnabled", value)
  300. },
  301. agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
  302. setAgent(value: string) {
  303. setStore("sounds", "agent", value)
  304. },
  305. permissionsEnabled: withFallback(
  306. () => store.sounds?.permissionsEnabled,
  307. defaultSettings.sounds.permissionsEnabled,
  308. ),
  309. setPermissionsEnabled(value: boolean) {
  310. setStore("sounds", "permissionsEnabled", value)
  311. },
  312. permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
  313. setPermissions(value: string) {
  314. setStore("sounds", "permissions", value)
  315. },
  316. errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
  317. setErrorsEnabled(value: boolean) {
  318. setStore("sounds", "errorsEnabled", value)
  319. },
  320. errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
  321. setErrors(value: string) {
  322. setStore("sounds", "errors", value)
  323. },
  324. },
  325. }
  326. },
  327. })