Adam 1 месяц назад
Родитель
Сommit
de3641e8eb

+ 63 - 0
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>
   )

+ 11 - 1
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,

+ 19 - 21
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
         }
       }

+ 56 - 4
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)
+        },
+      },
     }
   },
 })

+ 15 - 1
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

+ 44 - 0
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)
+}