wip(app): settings

This commit is contained in:
Adam
2026-01-06 16:03:39 -06:00
parent 8bcbfd6396
commit de3641e8eb
6 changed files with 208 additions and 27 deletions

View File

@@ -3,6 +3,7 @@ import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
export const SettingsGeneral: Component = () => { export const SettingsGeneral: Component = () => {
const theme = useTheme() const theme = useTheme()
@@ -20,11 +21,20 @@ export const SettingsGeneral: Component = () => {
const fontOptions = [ const fontOptions = [
{ value: "ibm-plex-mono", label: "IBM Plex Mono" }, { value: "ibm-plex-mono", label: "IBM Plex Mono" },
{ value: "cascadia-code", label: "Cascadia Code" },
{ value: "fira-code", label: "Fira Code" }, { value: "fira-code", label: "Fira Code" },
{ value: "hack", label: "Hack" },
{ value: "inconsolata", label: "Inconsolata" },
{ value: "intel-one-mono", label: "Intel One Mono" },
{ value: "jetbrains-mono", label: "JetBrains Mono" }, { value: "jetbrains-mono", label: "JetBrains Mono" },
{ value: "meslo-lgs", label: "Meslo LGS" },
{ value: "roboto-mono", label: "Roboto Mono" },
{ value: "source-code-pro", label: "Source Code Pro" }, { value: "source-code-pro", label: "Source Code Pro" },
{ value: "ubuntu-mono", label: "Ubuntu Mono" },
] ]
const soundOptions = [...SOUND_OPTIONS]
return ( return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar"> <div class="flex flex-col h-full overflow-y-auto no-scrollbar">
<div class="flex flex-col gap-8 p-8 max-w-[720px]"> <div class="flex flex-col gap-8 p-8 max-w-[720px]">
@@ -110,6 +120,59 @@ export const SettingsGeneral: Component = () => {
/> />
</SettingsRow> </SettingsRow>
</div> </div>
{/* Sound effects Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">Sound effects</h3>
<SettingsRow title="Agent" description="Play sound when the agent is complete or needs attention">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
settings.sounds.setAgent(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
/>
</SettingsRow>
<SettingsRow title="Permissions" description="Play sound when a permission is required">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
settings.sounds.setPermissions(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
/>
</SettingsRow>
<SettingsRow title="Errors" description="Play sound when an error occurs">
<Select
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
settings.sounds.setErrors(option.id)
playSound(option.src)
}}
variant="secondary"
size="small"
/>
</SettingsRow>
</div>
</div> </div>
</div> </div>
) )

View File

@@ -1,6 +1,7 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { monoFontFamily, useSettings } from "@/context/settings"
import { SerializeAddon } from "@/addons/serialize" import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal" import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
@@ -36,6 +37,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
export const Terminal = (props: TerminalProps) => { export const Terminal = (props: TerminalProps) => {
const sdk = useSDK() const sdk = useSDK()
const settings = useSettings()
const theme = useTheme() const theme = useTheme()
let container!: HTMLDivElement let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
@@ -82,6 +84,14 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors) setOption("theme", colors)
}) })
createEffect(() => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
if (!setOption) return
setOption("fontFamily", font)
})
const focusTerminal = () => { const focusTerminal = () => {
const t = term const t = term
if (!t) return if (!t) return
@@ -112,7 +122,7 @@ export const Terminal = (props: TerminalProps) => {
cursorBlink: true, cursorBlink: true,
cursorStyle: "bar", cursorStyle: "bar",
fontSize: 14, fontSize: 14,
fontFamily: "IBM Plex Mono, monospace", fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: true, allowTransparency: true,
theme: terminalColors(), theme: terminalColors(),
scrollback: 10_000, scrollback: 10_000,

View File

@@ -4,13 +4,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -44,19 +43,10 @@ function pruneNotifications(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification", name: "Notification",
init: () => { init: () => {
let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined
let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined
try {
idlePlayer = makeAudioPlayer(idleSound)
errorPlayer = makeAudioPlayer(errorSound)
} catch (err) {
console.log("Failed to load audio", err)
}
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]), Persist.global("notification", ["notification.v1"]),
@@ -93,16 +83,20 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const match = Binary.search(syncStore.session, sessionID, (s) => s.id) const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
const session = match.found ? syncStore.session[match.index] : undefined const session = match.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break if (session?.parentID) break
try {
idlePlayer?.play() playSound(soundSrc(settings.sounds.agent()))
} catch {}
append({ append({
...base, ...base,
type: "turn-complete", type: "turn-complete",
session: sessionID, session: sessionID,
}) })
const href = `/${base64Encode(directory)}/session/${sessionID}` const href = `/${base64Encode(directory)}/session/${sessionID}`
void platform.notify("Response ready", session?.title ?? sessionID, href) if (settings.notifications.agent()) {
void platform.notify("Response ready", session?.title ?? sessionID, href)
}
break break
} }
case "session.error": { case "session.error": {
@@ -111,9 +105,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
const session = sessionID && match?.found ? syncStore.session[match.index] : undefined const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
if (session?.parentID) break if (session?.parentID) break
try {
errorPlayer?.play() playSound(soundSrc(settings.sounds.errors()))
} catch {}
const error = "error" in event.properties ? event.properties.error : undefined const error = "error" in event.properties ? event.properties.error : undefined
append({ append({
...base, ...base,
@@ -121,9 +115,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
session: sessionID ?? "global", session: sessionID ?? "global",
error, error,
}) })
const description = session?.title ?? (typeof error === "string" ? error : "An error occurred") const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
void platform.notify("Session error", description, href) if (settings.notifications.errors()) {
void platform.notify("Session error", description, href)
}
break break
} }
} }

View File

@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMemo } from "solid-js" import { createEffect, createMemo } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { persisted } from "@/utils/persist" import { persisted } from "@/utils/persist"
@@ -9,6 +9,12 @@ export interface NotificationSettings {
errors: boolean errors: boolean
} }
export interface SoundSettings {
agent: string
permissions: string
errors: string
}
export interface Settings { export interface Settings {
general: { general: {
autoSave: boolean autoSave: boolean
@@ -22,6 +28,7 @@ export interface Settings {
autoApprove: boolean autoApprove: boolean
} }
notifications: NotificationSettings notifications: NotificationSettings
sounds: SoundSettings
} }
const defaultSettings: Settings = { const defaultSettings: Settings = {
@@ -37,16 +44,47 @@ const defaultSettings: Settings = {
autoApprove: false, autoApprove: false,
}, },
notifications: { notifications: {
agent: false, agent: true,
permissions: false, permissions: true,
errors: false, errors: false,
}, },
sounds: {
agent: "staplebops-01",
permissions: "staplebops-02",
errors: "nope-03",
},
}
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const monoFonts: Record<string, string> = {
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"cascadia-code": `"Cascadia Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"fira-code": `"Fira Code Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
hack: `"Hack Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
inconsolata: `"Inconsolata Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"intel-one-mono": `"Intel One Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"jetbrains-mono": `"JetBrains Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"meslo-lgs": `"Meslo LGS Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"roboto-mono": `"Roboto Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"source-code-pro": `"Source Code Pro Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
}
export function monoFontFamily(font: string | undefined) {
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
} }
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings", name: "Settings",
init: () => { init: () => {
const [store, setStore, _, ready] = persisted("settings.v1", createStore<Settings>(defaultSettings)) const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
createEffect(() => {
if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
})
return { return {
ready, ready,
@@ -98,6 +136,20 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setStore("notifications", "errors", value) setStore("notifications", "errors", value)
}, },
}, },
sounds: {
agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
setAgent(value: string) {
setStore("sounds", "agent", value)
},
permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
setPermissions(value: string) {
setStore("sounds", "permissions", value)
},
errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
setErrors(value: string) {
setStore("sounds", "errors", value)
},
},
} }
}, },
}) })

View File

@@ -37,6 +37,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { import {
DragDropProvider, DragDropProvider,
@@ -54,6 +55,7 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
@@ -98,6 +100,7 @@ export default function Layout(props: ParentProps) {
const layout = useLayout() const layout = useLayout()
const layoutReady = createMemo(() => layout.ready()) const layoutReady = createMemo(() => layout.ready())
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings()
const server = useServer() const server = useServer()
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
@@ -329,7 +332,18 @@ export default function Layout(props: ParentProps) {
if (now - lastAlerted < cooldownMs) return if (now - lastAlerted < cooldownMs) return
alertedAtBySession.set(sessionKey, now) alertedAtBySession.set(sessionKey, now)
void platform.notify(config.title, description, href) if (e.details.type === "permission.asked") {
playSound(soundSrc(settings.sounds.permissions()))
if (settings.notifications.permissions()) {
void platform.notify(config.title, description, href)
}
}
if (e.details.type === "question.asked") {
if (settings.notifications.agent()) {
void platform.notify(config.title, description, href)
}
}
const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id const currentSession = params.id

View File

@@ -0,0 +1,44 @@
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
export const SOUND_OPTIONS = [
{ id: "staplebops-01", label: "Boopy", src: staplebops01 },
{ id: "staplebops-02", label: "Beepy", src: staplebops02 },
{ id: "staplebops-03", label: "Staplebops 03", src: staplebops03 },
{ id: "staplebops-04", label: "Staplebops 04", src: staplebops04 },
{ id: "staplebops-05", label: "Staplebops 05", src: staplebops05 },
{ id: "staplebops-06", label: "Staplebops 06", src: staplebops06 },
{ id: "staplebops-07", label: "Staplebops 07", src: staplebops07 },
{ id: "nope-01", label: "Nope 01", src: nope01 },
{ id: "nope-02", label: "Nope 02", src: nope02 },
{ id: "nope-03", label: "Oopsie", src: nope03 },
{ id: "nope-04", label: "Nope 04", src: nope04 },
{ id: "nope-05", label: "Nope 05", src: nope05 },
] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string>
export function soundSrc(id: string | undefined) {
if (!id) return
if (!(id in soundById)) return
return soundById[id as SoundID]
}
export function playSound(src: string | undefined) {
if (typeof Audio === "undefined") return
if (!src) return
void new Audio(src).play().catch(() => undefined)
}