settings.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import { createStore, reconcile } from "solid-js/store"
  2. import { createEffect, createMemo, createSignal, onCleanup } from "solid-js"
  3. import { createSimpleContext } from "@opencode-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. layoutTransitionEligible?: boolean
  35. newInterfaceNoticeDismissed?: boolean
  36. }
  37. appearance: {
  38. fontSize: number
  39. mono: string
  40. sans: string
  41. terminal: string
  42. }
  43. keybinds: Record<string, string>
  44. permissions: {
  45. autoApprove: boolean
  46. }
  47. notifications: NotificationSettings
  48. sounds: SoundSettings
  49. }
  50. export const monoDefault = "System Mono"
  51. export const sansDefault = "System Sans"
  52. export const terminalDefault = "JetBrainsMono Nerd Font Mono"
  53. const legacyNewLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
  54. export const newLayoutDesignsDefault = true
  55. // Existing users can switch layouts until local midnight on this date. Set new Date(YYYY, M-1, D) to show.
  56. export const oldInterfaceSunset = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" ? new Date(2026, 7, 14) : null
  57. export function layoutTransitionState(scheduled: boolean, eligible: boolean, retired: boolean, dismissed: boolean) {
  58. return {
  59. available: scheduled && eligible && !retired,
  60. notice: scheduled && eligible && retired && !dismissed,
  61. }
  62. }
  63. export const maximumSunsetTimeout = 2_147_483_647
  64. export function nextSunsetCheckDelay(sunset: number, now: number) {
  65. return Math.min(Math.max(0, sunset - now), maximumSunsetTimeout)
  66. }
  67. export function resolveNewLayoutDesigns(retired: boolean, preference: boolean | undefined, fallback = true) {
  68. if (retired) return true
  69. return preference ?? fallback
  70. }
  71. const monoFallback =
  72. 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  73. const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
  74. const terminalFallback =
  75. '"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
  76. const monoBase = monoFallback
  77. const sansBase = sansFallback
  78. const terminalBase = terminalFallback
  79. function input(font: string | undefined) {
  80. return font ?? ""
  81. }
  82. function family(font: string) {
  83. if (/^[\w-]+$/.test(font)) return font
  84. return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
  85. }
  86. function stack(font: string | undefined, base: string) {
  87. const value = font?.trim() ?? ""
  88. if (!value) return base
  89. return `${family(value)}, ${base}`
  90. }
  91. export function monoInput(font: string | undefined) {
  92. return input(font)
  93. }
  94. export function sansInput(font: string | undefined) {
  95. return input(font)
  96. }
  97. export function monoFontFamily(font: string | undefined) {
  98. return stack(font, monoBase)
  99. }
  100. export function sansFontFamily(font: string | undefined) {
  101. return stack(font, sansBase)
  102. }
  103. export function terminalInput(font: string | undefined) {
  104. return input(font)
  105. }
  106. export function terminalFontFamily(font: string | undefined) {
  107. return stack(font, terminalBase)
  108. }
  109. const defaultSettings: Settings = {
  110. general: {
  111. autoSave: true,
  112. releaseNotes: true,
  113. followup: "steer",
  114. showFileTree: false,
  115. showNavigation: false,
  116. showSearch: false,
  117. showStatus: false,
  118. showTerminal: false,
  119. showReasoningSummaries: false,
  120. shellToolPartsExpanded: false,
  121. editToolPartsExpanded: false,
  122. showCustomAgents: false,
  123. mobileTitlebarPosition: "top",
  124. },
  125. appearance: {
  126. fontSize: 14,
  127. mono: "",
  128. sans: "",
  129. terminal: "",
  130. },
  131. keybinds: {},
  132. permissions: {
  133. autoApprove: false,
  134. },
  135. notifications: {
  136. agent: true,
  137. permissions: true,
  138. errors: false,
  139. },
  140. sounds: {
  141. agentEnabled: true,
  142. agent: "staplebops-01",
  143. permissionsEnabled: true,
  144. permissions: "staplebops-02",
  145. errorsEnabled: true,
  146. errors: "nope-03",
  147. },
  148. }
  149. function withFallback<T>(read: () => T | undefined, fallback: T) {
  150. return createMemo(() => read() ?? fallback)
  151. }
  152. export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
  153. name: "Settings",
  154. gate: false,
  155. init: () => {
  156. const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
  157. const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
  158. const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
  159. const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
  160. const showCustomAgents = withFallback(
  161. () => store.general?.showCustomAgents,
  162. defaultSettings.general.showCustomAgents,
  163. )
  164. const sunset = oldInterfaceSunset
  165. const [oldInterfaceRetired, setOldInterfaceRetired] = createSignal(sunset ? Date.now() >= sunset.getTime() : false)
  166. const layoutTransitionClassified = createMemo(() => typeof store.general?.layoutTransitionEligible === "boolean")
  167. const layoutTransitionEligible = withFallback(() => store.general?.layoutTransitionEligible, false)
  168. const newInterfaceNoticeDismissed = withFallback(() => store.general?.newInterfaceNoticeDismissed, false)
  169. const layoutTransition = createMemo(() =>
  170. layoutTransitionState(!!sunset, layoutTransitionEligible(), oldInterfaceRetired(), newInterfaceNoticeDismissed()),
  171. )
  172. const newLayoutDesigns = createMemo(() => {
  173. if (!ready() && !oldInterfaceRetired()) return legacyNewLayoutDesignsDefault
  174. if (!layoutTransitionClassified()) {
  175. return resolveNewLayoutDesigns(
  176. oldInterfaceRetired(),
  177. store.general?.newLayoutDesigns,
  178. legacyNewLayoutDesignsDefault,
  179. )
  180. }
  181. return resolveNewLayoutDesigns(
  182. oldInterfaceRetired(),
  183. store.general?.newLayoutDesigns,
  184. layoutTransitionEligible() ? legacyNewLayoutDesignsDefault : newLayoutDesignsDefault,
  185. )
  186. })
  187. const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
  188. if (sunset && !oldInterfaceRetired()) {
  189. const timeout = { current: undefined as ReturnType<typeof setTimeout> | undefined }
  190. const checkSunset = () => {
  191. if (Date.now() >= sunset.getTime()) {
  192. setOldInterfaceRetired(true)
  193. return
  194. }
  195. timeout.current = setTimeout(checkSunset, nextSunsetCheckDelay(sunset.getTime(), Date.now()))
  196. }
  197. checkSunset()
  198. onCleanup(() => {
  199. if (timeout.current !== undefined) clearTimeout(timeout.current)
  200. })
  201. }
  202. createEffect(() => {
  203. if (!ready() || !oldInterfaceRetired()) return
  204. if (store.general?.newLayoutDesigns === true) return
  205. setStore("general", "newLayoutDesigns", true)
  206. })
  207. createEffect(() => {
  208. if (typeof document === "undefined") return
  209. const root = document.documentElement
  210. root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
  211. root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
  212. })
  213. createEffect(() => {
  214. if (store.general?.followup !== "queue") return
  215. setStore("general", "followup", "steer")
  216. })
  217. return {
  218. ready,
  219. get current() {
  220. return store
  221. },
  222. general: {
  223. autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
  224. setAutoSave(value: boolean) {
  225. setStore("general", "autoSave", value)
  226. },
  227. releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
  228. setReleaseNotes(value: boolean) {
  229. setStore("general", "releaseNotes", value)
  230. },
  231. followup: withFallback(
  232. () => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
  233. defaultSettings.general.followup,
  234. ),
  235. setFollowup(value: "queue" | "steer") {
  236. setStore("general", "followup", value === "queue" ? "steer" : value)
  237. },
  238. showFileTree,
  239. setShowFileTree(value: boolean) {
  240. setStore("general", "showFileTree", value)
  241. },
  242. showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
  243. setShowNavigation(value: boolean) {
  244. setStore("general", "showNavigation", value)
  245. },
  246. showSearch,
  247. setShowSearch(value: boolean) {
  248. setStore("general", "showSearch", value)
  249. },
  250. showStatus,
  251. setShowStatus(value: boolean) {
  252. setStore("general", "showStatus", value)
  253. },
  254. showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
  255. setShowTerminal(value: boolean) {
  256. setStore("general", "showTerminal", value)
  257. },
  258. showReasoningSummaries: withFallback(
  259. () => store.general?.showReasoningSummaries,
  260. defaultSettings.general.showReasoningSummaries,
  261. ),
  262. setShowReasoningSummaries(value: boolean) {
  263. setStore("general", "showReasoningSummaries", value)
  264. },
  265. shellToolPartsExpanded: withFallback(
  266. () => store.general?.shellToolPartsExpanded,
  267. defaultSettings.general.shellToolPartsExpanded,
  268. ),
  269. setShellToolPartsExpanded(value: boolean) {
  270. setStore("general", "shellToolPartsExpanded", value)
  271. },
  272. editToolPartsExpanded: withFallback(
  273. () => store.general?.editToolPartsExpanded,
  274. defaultSettings.general.editToolPartsExpanded,
  275. ),
  276. setEditToolPartsExpanded(value: boolean) {
  277. setStore("general", "editToolPartsExpanded", value)
  278. },
  279. showCustomAgents,
  280. setShowCustomAgents(value: boolean) {
  281. setStore("general", "showCustomAgents", value)
  282. },
  283. mobileTitlebarPosition: withFallback(
  284. () => store.general?.mobileTitlebarPosition,
  285. defaultSettings.general.mobileTitlebarPosition,
  286. ),
  287. setMobileTitlebarPosition(value: "top" | "bottom") {
  288. setStore("general", "mobileTitlebarPosition", value)
  289. },
  290. newLayoutDesigns,
  291. setNewLayoutDesigns(value: boolean) {
  292. setStore("general", "newLayoutDesigns", oldInterfaceRetired() ? true : value)
  293. },
  294. layoutTransitionClassified,
  295. setOldLayoutEligible(eligible: boolean) {
  296. const current = store.general?.layoutTransitionEligible
  297. if (typeof current === "boolean") return
  298. setStore("general", "layoutTransitionEligible", eligible)
  299. },
  300. layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available),
  301. newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice),
  302. dismissNewInterfaceNotice() {
  303. setStore("general", "newInterfaceNoticeDismissed", true)
  304. },
  305. },
  306. visibility: {
  307. fileTree: visible(showFileTree),
  308. search: visible(showSearch),
  309. status: visible(showStatus),
  310. customAgents: visible(showCustomAgents),
  311. },
  312. appearance: {
  313. fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
  314. setFontSize(value: number) {
  315. setStore("appearance", "fontSize", value)
  316. },
  317. font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
  318. setFont(value: string) {
  319. setStore("appearance", "mono", value.trim() ? value : "")
  320. },
  321. uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
  322. setUIFont(value: string) {
  323. setStore("appearance", "sans", value.trim() ? value : "")
  324. },
  325. terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
  326. setTerminalFont(value: string) {
  327. setStore("appearance", "terminal", value.trim() ? value : "")
  328. },
  329. },
  330. keybinds: {
  331. get: (action: string) => store.keybinds?.[action],
  332. set(action: string, keybind: string) {
  333. setStore("keybinds", action, keybind)
  334. },
  335. reset(action: string) {
  336. setStore("keybinds", (current) => {
  337. if (!Object.prototype.hasOwnProperty.call(current, action)) return current
  338. const next = { ...current }
  339. delete next[action]
  340. return next
  341. })
  342. },
  343. resetAll() {
  344. setStore("keybinds", reconcile({}))
  345. },
  346. },
  347. permissions: {
  348. autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
  349. setAutoApprove(value: boolean) {
  350. setStore("permissions", "autoApprove", value)
  351. },
  352. },
  353. notifications: {
  354. agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
  355. setAgent(value: boolean) {
  356. setStore("notifications", "agent", value)
  357. },
  358. permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
  359. setPermissions(value: boolean) {
  360. setStore("notifications", "permissions", value)
  361. },
  362. errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
  363. setErrors(value: boolean) {
  364. setStore("notifications", "errors", value)
  365. },
  366. },
  367. sounds: {
  368. agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
  369. setAgentEnabled(value: boolean) {
  370. setStore("sounds", "agentEnabled", value)
  371. },
  372. agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
  373. setAgent(value: string) {
  374. setStore("sounds", "agent", value)
  375. },
  376. permissionsEnabled: withFallback(
  377. () => store.sounds?.permissionsEnabled,
  378. defaultSettings.sounds.permissionsEnabled,
  379. ),
  380. setPermissionsEnabled(value: boolean) {
  381. setStore("sounds", "permissionsEnabled", value)
  382. },
  383. permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
  384. setPermissions(value: string) {
  385. setStore("sounds", "permissions", value)
  386. },
  387. errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
  388. setErrorsEnabled(value: boolean) {
  389. setStore("sounds", "errorsEnabled", value)
  390. },
  391. errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
  392. setErrors(value: string) {
  393. setStore("sounds", "errors", value)
  394. },
  395. },
  396. }
  397. },
  398. })