Users who were migrated to the new interface will now see a dismissible notice in Settings explaining the old interface was phased out, so they are not surprised by the change. The new-interface toggle is also visually elevated with a card style so it is easier to find. Settings labels and descriptions across General have been rewritten to be shorter and more direct, so it is quicker to understand what each option controls. The "New" badge color is also adjusted for better readability.
358 lines
12 KiB
TypeScript
358 lines
12 KiB
TypeScript
import { createStore, reconcile } from "solid-js/store"
|
|
import { createEffect, createMemo } from "solid-js"
|
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
import { persisted } from "@/utils/persist"
|
|
|
|
export interface NotificationSettings {
|
|
agent: boolean
|
|
permissions: boolean
|
|
errors: boolean
|
|
}
|
|
|
|
export interface SoundSettings {
|
|
agentEnabled: boolean
|
|
agent: string
|
|
permissionsEnabled: boolean
|
|
permissions: string
|
|
errorsEnabled: boolean
|
|
errors: string
|
|
}
|
|
|
|
export interface Settings {
|
|
general: {
|
|
autoSave: boolean
|
|
releaseNotes: boolean
|
|
followup: "queue" | "steer"
|
|
showFileTree: boolean
|
|
showNavigation: boolean
|
|
showSearch: boolean
|
|
showStatus: boolean
|
|
showTerminal: boolean
|
|
showReasoningSummaries: boolean
|
|
shellToolPartsExpanded: boolean
|
|
editToolPartsExpanded: boolean
|
|
showCustomAgents: boolean
|
|
mobileTitlebarPosition: "top" | "bottom"
|
|
newLayoutDesigns?: boolean
|
|
newInterfaceNoticeDismissed?: boolean
|
|
}
|
|
appearance: {
|
|
fontSize: number
|
|
mono: string
|
|
sans: string
|
|
terminal: string
|
|
}
|
|
keybinds: Record<string, string>
|
|
permissions: {
|
|
autoApprove: boolean
|
|
}
|
|
notifications: NotificationSettings
|
|
sounds: SoundSettings
|
|
}
|
|
|
|
export const monoDefault = "System Mono"
|
|
export const sansDefault = "System Sans"
|
|
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
|
|
export const newLayoutDesignsDefault = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
|
// Date the old interface is retired; the settings toggle references this countdown.
|
|
export const oldInterfaceSunset = new Date(2026, 7, 6)
|
|
|
|
const monoFallback =
|
|
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
|
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
|
const terminalFallback =
|
|
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
|
|
|
const monoBase = monoFallback
|
|
const sansBase = sansFallback
|
|
const terminalBase = terminalFallback
|
|
|
|
function input(font: string | undefined) {
|
|
return font ?? ""
|
|
}
|
|
|
|
function family(font: string) {
|
|
if (/^[\w-]+$/.test(font)) return font
|
|
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
|
}
|
|
|
|
function stack(font: string | undefined, base: string) {
|
|
const value = font?.trim() ?? ""
|
|
if (!value) return base
|
|
return `${family(value)}, ${base}`
|
|
}
|
|
|
|
export function monoInput(font: string | undefined) {
|
|
return input(font)
|
|
}
|
|
|
|
export function sansInput(font: string | undefined) {
|
|
return input(font)
|
|
}
|
|
|
|
export function monoFontFamily(font: string | undefined) {
|
|
return stack(font, monoBase)
|
|
}
|
|
|
|
export function sansFontFamily(font: string | undefined) {
|
|
return stack(font, sansBase)
|
|
}
|
|
|
|
export function terminalInput(font: string | undefined) {
|
|
return input(font)
|
|
}
|
|
|
|
export function terminalFontFamily(font: string | undefined) {
|
|
return stack(font, terminalBase)
|
|
}
|
|
|
|
const defaultSettings: Settings = {
|
|
general: {
|
|
autoSave: true,
|
|
releaseNotes: true,
|
|
followup: "steer",
|
|
showFileTree: false,
|
|
showNavigation: false,
|
|
showSearch: false,
|
|
showStatus: false,
|
|
showTerminal: false,
|
|
showReasoningSummaries: false,
|
|
shellToolPartsExpanded: false,
|
|
editToolPartsExpanded: false,
|
|
showCustomAgents: false,
|
|
mobileTitlebarPosition: "top",
|
|
},
|
|
appearance: {
|
|
fontSize: 14,
|
|
mono: "",
|
|
sans: "",
|
|
terminal: "",
|
|
},
|
|
keybinds: {},
|
|
permissions: {
|
|
autoApprove: false,
|
|
},
|
|
notifications: {
|
|
agent: true,
|
|
permissions: true,
|
|
errors: false,
|
|
},
|
|
sounds: {
|
|
agentEnabled: true,
|
|
agent: "staplebops-01",
|
|
permissionsEnabled: true,
|
|
permissions: "staplebops-02",
|
|
errorsEnabled: true,
|
|
errors: "nope-03",
|
|
},
|
|
}
|
|
|
|
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
|
return createMemo(() => read() ?? fallback)
|
|
}
|
|
|
|
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
|
name: "Settings",
|
|
gate: false,
|
|
init: () => {
|
|
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
|
|
const showFileTree = withFallback(() => store.general?.showFileTree, defaultSettings.general.showFileTree)
|
|
const showSearch = withFallback(() => store.general?.showSearch, defaultSettings.general.showSearch)
|
|
const showStatus = withFallback(() => store.general?.showStatus, defaultSettings.general.showStatus)
|
|
const showCustomAgents = withFallback(
|
|
() => store.general?.showCustomAgents,
|
|
defaultSettings.general.showCustomAgents,
|
|
)
|
|
const newLayoutDesigns = withFallback(() => store.general?.newLayoutDesigns, newLayoutDesignsDefault)
|
|
const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference())
|
|
|
|
createEffect(() => {
|
|
if (typeof document === "undefined") return
|
|
const root = document.documentElement
|
|
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
|
|
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (store.general?.followup !== "queue") return
|
|
setStore("general", "followup", "steer")
|
|
})
|
|
|
|
return {
|
|
ready,
|
|
get current() {
|
|
return store
|
|
},
|
|
general: {
|
|
autoSave: withFallback(() => store.general?.autoSave, defaultSettings.general.autoSave),
|
|
setAutoSave(value: boolean) {
|
|
setStore("general", "autoSave", value)
|
|
},
|
|
releaseNotes: withFallback(() => store.general?.releaseNotes, defaultSettings.general.releaseNotes),
|
|
setReleaseNotes(value: boolean) {
|
|
setStore("general", "releaseNotes", value)
|
|
},
|
|
followup: withFallback(
|
|
() => (store.general?.followup === "queue" ? "steer" : store.general?.followup),
|
|
defaultSettings.general.followup,
|
|
),
|
|
setFollowup(value: "queue" | "steer") {
|
|
setStore("general", "followup", value === "queue" ? "steer" : value)
|
|
},
|
|
showFileTree,
|
|
setShowFileTree(value: boolean) {
|
|
setStore("general", "showFileTree", value)
|
|
},
|
|
showNavigation: withFallback(() => store.general?.showNavigation, defaultSettings.general.showNavigation),
|
|
setShowNavigation(value: boolean) {
|
|
setStore("general", "showNavigation", value)
|
|
},
|
|
showSearch,
|
|
setShowSearch(value: boolean) {
|
|
setStore("general", "showSearch", value)
|
|
},
|
|
showStatus,
|
|
setShowStatus(value: boolean) {
|
|
setStore("general", "showStatus", value)
|
|
},
|
|
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
|
|
setShowTerminal(value: boolean) {
|
|
setStore("general", "showTerminal", value)
|
|
},
|
|
showReasoningSummaries: withFallback(
|
|
() => store.general?.showReasoningSummaries,
|
|
defaultSettings.general.showReasoningSummaries,
|
|
),
|
|
setShowReasoningSummaries(value: boolean) {
|
|
setStore("general", "showReasoningSummaries", value)
|
|
},
|
|
shellToolPartsExpanded: withFallback(
|
|
() => store.general?.shellToolPartsExpanded,
|
|
defaultSettings.general.shellToolPartsExpanded,
|
|
),
|
|
setShellToolPartsExpanded(value: boolean) {
|
|
setStore("general", "shellToolPartsExpanded", value)
|
|
},
|
|
editToolPartsExpanded: withFallback(
|
|
() => store.general?.editToolPartsExpanded,
|
|
defaultSettings.general.editToolPartsExpanded,
|
|
),
|
|
setEditToolPartsExpanded(value: boolean) {
|
|
setStore("general", "editToolPartsExpanded", value)
|
|
},
|
|
showCustomAgents,
|
|
setShowCustomAgents(value: boolean) {
|
|
setStore("general", "showCustomAgents", value)
|
|
},
|
|
mobileTitlebarPosition: withFallback(
|
|
() => store.general?.mobileTitlebarPosition,
|
|
defaultSettings.general.mobileTitlebarPosition,
|
|
),
|
|
setMobileTitlebarPosition(value: "top" | "bottom") {
|
|
setStore("general", "mobileTitlebarPosition", value)
|
|
},
|
|
newLayoutDesigns,
|
|
setNewLayoutDesigns(value: boolean) {
|
|
setStore("general", "newLayoutDesigns", value)
|
|
},
|
|
newInterfaceNoticeDismissed: withFallback(
|
|
() => store.general?.newInterfaceNoticeDismissed,
|
|
false,
|
|
),
|
|
dismissNewInterfaceNotice() {
|
|
setStore("general", "newInterfaceNoticeDismissed", true)
|
|
},
|
|
},
|
|
visibility: {
|
|
fileTree: visible(showFileTree),
|
|
search: visible(showSearch),
|
|
status: visible(showStatus),
|
|
customAgents: visible(showCustomAgents),
|
|
},
|
|
appearance: {
|
|
fontSize: withFallback(() => store.appearance?.fontSize, defaultSettings.appearance.fontSize),
|
|
setFontSize(value: number) {
|
|
setStore("appearance", "fontSize", value)
|
|
},
|
|
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
|
|
setFont(value: string) {
|
|
setStore("appearance", "mono", value.trim() ? value : "")
|
|
},
|
|
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
|
|
setUIFont(value: string) {
|
|
setStore("appearance", "sans", value.trim() ? value : "")
|
|
},
|
|
terminalFont: withFallback(() => store.appearance?.terminal, defaultSettings.appearance.terminal),
|
|
setTerminalFont(value: string) {
|
|
setStore("appearance", "terminal", value.trim() ? value : "")
|
|
},
|
|
},
|
|
keybinds: {
|
|
get: (action: string) => store.keybinds?.[action],
|
|
set(action: string, keybind: string) {
|
|
setStore("keybinds", action, keybind)
|
|
},
|
|
reset(action: string) {
|
|
setStore("keybinds", (current) => {
|
|
if (!Object.prototype.hasOwnProperty.call(current, action)) return current
|
|
const next = { ...current }
|
|
delete next[action]
|
|
return next
|
|
})
|
|
},
|
|
resetAll() {
|
|
setStore("keybinds", reconcile({}))
|
|
},
|
|
},
|
|
permissions: {
|
|
autoApprove: withFallback(() => store.permissions?.autoApprove, defaultSettings.permissions.autoApprove),
|
|
setAutoApprove(value: boolean) {
|
|
setStore("permissions", "autoApprove", value)
|
|
},
|
|
},
|
|
notifications: {
|
|
agent: withFallback(() => store.notifications?.agent, defaultSettings.notifications.agent),
|
|
setAgent(value: boolean) {
|
|
setStore("notifications", "agent", value)
|
|
},
|
|
permissions: withFallback(() => store.notifications?.permissions, defaultSettings.notifications.permissions),
|
|
setPermissions(value: boolean) {
|
|
setStore("notifications", "permissions", value)
|
|
},
|
|
errors: withFallback(() => store.notifications?.errors, defaultSettings.notifications.errors),
|
|
setErrors(value: boolean) {
|
|
setStore("notifications", "errors", value)
|
|
},
|
|
},
|
|
sounds: {
|
|
agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled),
|
|
setAgentEnabled(value: boolean) {
|
|
setStore("sounds", "agentEnabled", value)
|
|
},
|
|
agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent),
|
|
setAgent(value: string) {
|
|
setStore("sounds", "agent", value)
|
|
},
|
|
permissionsEnabled: withFallback(
|
|
() => store.sounds?.permissionsEnabled,
|
|
defaultSettings.sounds.permissionsEnabled,
|
|
),
|
|
setPermissionsEnabled(value: boolean) {
|
|
setStore("sounds", "permissionsEnabled", value)
|
|
},
|
|
permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions),
|
|
setPermissions(value: string) {
|
|
setStore("sounds", "permissions", value)
|
|
},
|
|
errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled),
|
|
setErrorsEnabled(value: boolean) {
|
|
setStore("sounds", "errorsEnabled", value)
|
|
},
|
|
errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors),
|
|
setErrors(value: string) {
|
|
setStore("sounds", "errors", value)
|
|
},
|
|
},
|
|
}
|
|
},
|
|
})
|