wip(app): settings
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
packages/app/src/utils/sound.ts
Normal file
44
packages/app/src/utils/sound.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user