diff options
| -rw-r--r-- | packages/app/src/components/settings-general.tsx | 63 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 12 | ||||
| -rw-r--r-- | packages/app/src/context/notification.tsx | 40 | ||||
| -rw-r--r-- | packages/app/src/context/settings.tsx | 60 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 16 | ||||
| -rw-r--r-- | packages/app/src/utils/sound.ts | 44 |
6 files changed, 208 insertions, 27 deletions
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index e9965b0fa..52672d01f 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -3,6 +3,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useSettings } from "@/context/settings" +import { playSound, SOUND_OPTIONS } from "@/utils/sound" export const SettingsGeneral: Component = () => { const theme = useTheme() @@ -20,11 +21,20 @@ export const SettingsGeneral: Component = () => { const fontOptions = [ { value: "ibm-plex-mono", label: "IBM Plex Mono" }, + { value: "cascadia-code", label: "Cascadia 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: "meslo-lgs", label: "Meslo LGS" }, + { value: "roboto-mono", label: "Roboto Mono" }, { value: "source-code-pro", label: "Source Code Pro" }, + { value: "ubuntu-mono", label: "Ubuntu Mono" }, ] + const soundOptions = [...SOUND_OPTIONS] + return ( <div class="flex flex-col h-full overflow-y-auto no-scrollbar"> <div class="flex flex-col gap-8 p-8 max-w-[720px]"> @@ -110,6 +120,59 @@ export const SettingsGeneral: Component = () => { /> </SettingsRow> </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> ) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8001e2caa..f19366b8a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,6 +1,7 @@ import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" import { useSDK } from "@/context/sdk" +import { monoFontFamily, useSettings } from "@/context/settings" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" 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) => { const sdk = useSDK() + const settings = useSettings() const theme = useTheme() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"]) @@ -82,6 +84,14 @@ export const Terminal = (props: TerminalProps) => { 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 t = term if (!t) return @@ -112,7 +122,7 @@ export const Terminal = (props: TerminalProps) => { cursorBlink: true, cursorStyle: "bar", fontSize: 14, - fontFamily: "IBM Plex Mono, monospace", + fontFamily: monoFontFamily(settings.appearance.font()), allowTransparency: true, theme: terminalColors(), scrollback: 10_000, diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 16b3d306c..8b1088519 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -4,13 +4,12 @@ import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" import { useGlobalSync } from "./global-sync" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { Binary } from "@opencode-ai/util/binary" import { base64Encode } from "@opencode-ai/util/encode" 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 { playSound, soundSrc } from "@/utils/sound" type NotificationBase = { directory?: string @@ -44,19 +43,10 @@ function pruneNotifications(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", 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 globalSync = useGlobalSync() const platform = usePlatform() + const settings = useSettings() const [store, setStore, _, ready] = persisted( 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 session = match.found ? syncStore.session[match.index] : undefined if (session?.parentID) break - try { - idlePlayer?.play() - } catch {} + + playSound(soundSrc(settings.sounds.agent())) + append({ ...base, type: "turn-complete", 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 } 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 session = sessionID && match?.found ? syncStore.session[match.index] : undefined if (session?.parentID) break - try { - errorPlayer?.play() - } catch {} + + playSound(soundSrc(settings.sounds.errors())) + const error = "error" in event.properties ? event.properties.error : undefined append({ ...base, @@ -121,9 +115,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi session: sessionID ?? "global", error, }) + const description = session?.title ?? (typeof error === "string" ? error : "An error occurred") 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 } } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 6aca57ae2..4160d1b70 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,5 +1,5 @@ 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 { persisted } from "@/utils/persist" @@ -9,6 +9,12 @@ export interface NotificationSettings { errors: boolean } +export interface SoundSettings { + agent: string + permissions: string + errors: string +} + export interface Settings { general: { autoSave: boolean @@ -22,6 +28,7 @@ export interface Settings { autoApprove: boolean } notifications: NotificationSettings + sounds: SoundSettings } const defaultSettings: Settings = { @@ -37,16 +44,47 @@ const defaultSettings: Settings = { autoApprove: false, }, notifications: { - agent: false, - permissions: false, + agent: true, + permissions: true, 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({ name: "Settings", 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 { ready, @@ -98,6 +136,20 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont 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) + }, + }, } }, }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8c04f10db..f4e202b6e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -37,6 +37,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { getFilename } from "@opencode-ai/util/path" import { Session, type Message, type TextPart } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, @@ -54,6 +55,7 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" +import { playSound, soundSrc } from "@/utils/sound" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" @@ -98,6 +100,7 @@ export default function Layout(props: ParentProps) { const layout = useLayout() const layoutReady = createMemo(() => layout.ready()) const platform = usePlatform() + const settings = useSettings() const server = useServer() const notification = useNotification() const permission = usePermission() @@ -329,7 +332,18 @@ export default function Layout(props: ParentProps) { if (now - lastAlerted < cooldownMs) return 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 currentSession = params.id diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts new file mode 100644 index 000000000..e8db0bf7b --- /dev/null +++ b/packages/app/src/utils/sound.ts @@ -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) +} |
