wip(app): settings

This commit is contained in:
Adam
2026-01-06 15:21:00 -06:00
parent e521fee002
commit 8bcbfd6396
16 changed files with 564 additions and 45 deletions

View File

@@ -14,6 +14,7 @@ import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server" import { ServerProvider, useServer } from "@/context/server"
import { SettingsProvider } from "@/context/settings"
import { TerminalProvider } from "@/context/terminal" import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt" import { PromptProvider } from "@/context/prompt"
import { FileProvider } from "@/context/file" import { FileProvider } from "@/context/file"
@@ -82,15 +83,17 @@ export function AppInterface(props: { defaultUrl?: string }) {
<GlobalSyncProvider> <GlobalSyncProvider>
<Router <Router
root={(props) => ( root={(props) => (
<PermissionProvider> <SettingsProvider>
<LayoutProvider> <PermissionProvider>
<NotificationProvider> <LayoutProvider>
<CommandProvider> <NotificationProvider>
<Layout>{props.children}</Layout> <CommandProvider>
</CommandProvider> <Layout>{props.children}</Layout>
</NotificationProvider> </CommandProvider>
</LayoutProvider> </NotificationProvider>
</PermissionProvider> </LayoutProvider>
</PermissionProvider>
</SettingsProvider>
)} )}
> >
<Route <Route
@@ -105,16 +108,18 @@ export function AppInterface(props: { defaultUrl?: string }) {
<Route path="/" component={() => <Navigate href="session" />} /> <Route path="/" component={() => <Navigate href="session" />} />
<Route <Route
path="/session/:id?" path="/session/:id?"
component={() => ( component={(p) => (
<TerminalProvider> <Show when={p.params.id ?? "new"} keyed>
<FileProvider> <TerminalProvider>
<PromptProvider> <FileProvider>
<Suspense fallback={<Loading />}> <PromptProvider>
<Session /> <Suspense fallback={<Loading />}>
</Suspense> <Session />
</PromptProvider> </Suspense>
</FileProvider> </PromptProvider>
</TerminalProvider> </FileProvider>
</TerminalProvider>
</Show>
)} )}
/> />
</Route> </Route>

View File

@@ -0,0 +1,87 @@
import { Component, createSignal } from "solid-js"
import { Dialog } from "@opencode-ai/ui/dialog"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Icon } from "@opencode-ai/ui/icon"
import { TextField } from "@opencode-ai/ui/text-field"
import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsPermissions } from "./settings-permissions"
import { SettingsProviders } from "./settings-providers"
import { SettingsModels } from "./settings-models"
import { SettingsAgents } from "./settings-agents"
import { SettingsCommands } from "./settings-commands"
import { SettingsMcp } from "./settings-mcp"
export const DialogSettings: Component = () => {
const [search, setSearch] = createSignal("")
return (
<Dialog size="large">
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
<Tabs.List>
<div class="settings-dialog__search px-3 pb-3">
<TextField placeholder="Search" value={search()} onChange={setSearch} variant="normal" />
</div>
<Tabs.SectionTitle>Desktop</Tabs.SectionTitle>
<Tabs.Trigger value="general">
<Icon name="settings-gear" />
General
</Tabs.Trigger>
<Tabs.Trigger value="shortcuts">
<Icon name="console" />
Shortcuts
</Tabs.Trigger>
<Tabs.SectionTitle>Server</Tabs.SectionTitle>
<Tabs.Trigger value="permissions">
<Icon name="checklist" />
Permissions
</Tabs.Trigger>
<Tabs.Trigger value="providers">
<Icon name="server" />
Providers
</Tabs.Trigger>
<Tabs.Trigger value="models">
<Icon name="brain" />
Models
</Tabs.Trigger>
<Tabs.Trigger value="agents">
<Icon name="task" />
Agents
</Tabs.Trigger>
<Tabs.Trigger value="commands">
<Icon name="console" />
Commands
</Tabs.Trigger>
<Tabs.Trigger value="mcp">
<Icon name="mcp" />
MCP
</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="general" class="no-scrollbar">
<SettingsGeneral />
</Tabs.Content>
<Tabs.Content value="shortcuts" class="no-scrollbar">
<SettingsKeybinds />
</Tabs.Content>
<Tabs.Content value="permissions" class="no-scrollbar">
<SettingsPermissions />
</Tabs.Content>
<Tabs.Content value="providers" class="no-scrollbar">
<SettingsProviders />
</Tabs.Content>
<Tabs.Content value="models" class="no-scrollbar">
<SettingsModels />
</Tabs.Content>
<Tabs.Content value="agents" class="no-scrollbar">
<SettingsAgents />
</Tabs.Content>
<Tabs.Content value="commands" class="no-scrollbar">
<SettingsCommands />
</Tabs.Content>
<Tabs.Content value="mcp" class="no-scrollbar">
<SettingsMcp />
</Tabs.Content>
</Tabs>
</Dialog>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsAgents: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Agents</h2>
<p class="text-14-regular text-text-weak">Agent settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsCommands: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Commands</h2>
<p class="text-14-regular text-text-weak">Command settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,134 @@
import { Component, createMemo, type JSX } from "solid-js"
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"
export const SettingsGeneral: Component = () => {
const theme = useTheme()
const settings = useSettings()
const themeOptions = createMemo(() =>
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const colorSchemeOptions: { value: ColorScheme; label: string }[] = [
{ value: "system", label: "System setting" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]
const fontOptions = [
{ value: "ibm-plex-mono", label: "IBM Plex Mono" },
{ value: "fira-code", label: "Fira Code" },
{ value: "jetbrains-mono", label: "JetBrains Mono" },
{ value: "source-code-pro", label: "Source Code Pro" },
]
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]">
{/* Header */}
<h2 class="text-16-medium text-text-strong">General</h2>
{/* Appearance Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">Appearance</h3>
<SettingsRow title="Appearance" description="Customise how OpenCode looks on your device">
<Select
options={colorSchemeOptions}
current={colorSchemeOptions.find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && theme.setColorScheme(option.value)}
variant="secondary"
size="small"
/>
</SettingsRow>
<SettingsRow
title="Theme"
description={
<>
Customise how OpenCode is themed.{" "}
<a href="#" class="text-text-interactive-base">
Learn more
</a>
</>
}
>
<Select
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
onSelect={(option) => option && theme.setTheme(option.id)}
variant="secondary"
size="small"
/>
</SettingsRow>
<SettingsRow title="Font" description="Customise the mono font used in code blocks">
<Select
options={fontOptions}
current={fontOptions.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary"
size="small"
/>
</SettingsRow>
</div>
{/* System notifications Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">System notifications</h3>
<SettingsRow
title="Agent"
description="Show system notification when the agent is complete or needs attention"
>
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</SettingsRow>
<SettingsRow title="Permissions" description="Show system notification when a permission is required">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</SettingsRow>
<SettingsRow title="Errors" description="Show system notification when an error occurs">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</SettingsRow>
</div>
</div>
</div>
)
}
interface SettingsRowProps {
title: string
description: string | JSX.Element
children: JSX.Element
}
const SettingsRow: Component<SettingsRowProps> = (props) => {
return (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<div class="flex flex-col gap-0.5">
<span class="text-14-medium text-text-strong">{props.title}</span>
<span class="text-12-regular text-text-weak">{props.description}</span>
</div>
<div class="flex-shrink-0">{props.children}</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsKeybinds: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Shortcuts</h2>
<p class="text-14-regular text-text-weak">Keyboard shortcuts will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsMcp: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">MCP</h2>
<p class="text-14-regular text-text-weak">MCP settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsModels: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Models</h2>
<p class="text-14-regular text-text-weak">Model settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsPermissions: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Permissions</h2>
<p class="text-14-regular text-text-weak">Permission settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,12 @@
import { Component } from "solid-js"
export const SettingsProviders: Component = () => {
return (
<div class="flex flex-col h-full overflow-y-auto">
<div class="flex flex-col gap-6 p-6 max-w-[600px]">
<h2 class="text-16-medium text-text-strong">Providers</h2>
<p class="text-14-regular text-text-weak">Provider settings will be configurable here.</p>
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
import { createStore } from "solid-js/store"
import { 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 Settings {
general: {
autoSave: boolean
}
appearance: {
fontSize: number
font: string
}
keybinds: Record<string, string>
permissions: {
autoApprove: boolean
}
notifications: NotificationSettings
}
const defaultSettings: Settings = {
general: {
autoSave: true,
},
appearance: {
fontSize: 14,
font: "ibm-plex-mono",
},
keybinds: {},
permissions: {
autoApprove: false,
},
notifications: {
agent: false,
permissions: false,
errors: false,
},
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
init: () => {
const [store, setStore, _, ready] = persisted("settings.v1", createStore<Settings>(defaultSettings))
return {
ready,
get current() {
return store
},
general: {
autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
setAutoSave(value: boolean) {
setStore("general", "autoSave", value)
},
},
appearance: {
fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
setFont(value: string) {
setStore("appearance", "font", value)
},
},
keybinds: {
get: (action: string) => store.keybinds?.[action],
set(action: string, keybind: string) {
setStore("keybinds", action, keybind)
},
reset(action: string) {
setStore("keybinds", action, undefined!)
},
},
permissions: {
autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
setAutoApprove(value: boolean) {
setStore("permissions", "autoApprove", value)
},
},
notifications: {
agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
setAgent(value: boolean) {
setStore("notifications", "agent", value)
},
permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
setPermissions(value: boolean) {
setStore("notifications", "permissions", value)
},
errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
setErrors(value: boolean) {
setStore("notifications", "errors", value)
},
},
}
},
})

View File

@@ -59,6 +59,7 @@ 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"
import { DialogSelectProvider } from "@/components/dialog-select-provider" import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server" import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf" import { navStart } from "@/utils/perf"
@@ -880,6 +881,10 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSelectServer />) dialog.show(() => <DialogSelectServer />)
} }
function openSettings() {
dialog.show(() => <DialogSettings />)
}
function navigateToProject(directory: string | undefined) { function navigateToProject(directory: string | undefined) {
if (!directory) return if (!directory) return
server.projects.touch(directory) server.projects.touch(directory)

View File

@@ -30,6 +30,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-items: start; justify-items: start;
overflow: visible;
[data-slot="dialog-content"] { [data-slot="dialog-content"] {
display: flex; display: flex;
@@ -39,6 +40,14 @@
width: 100%; width: 100%;
max-height: 100%; max-height: 100%;
min-height: 280px; min-height: 280px;
overflow: auto;
/* Hide scrollbar */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
/* padding: 8px; */ /* padding: 8px; */
/* padding: 8px 8px 0 8px; */ /* padding: 8px 8px 0 8px; */
@@ -108,7 +117,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
overflow-y: auto; overflow: hidden;
&:focus-visible { &:focus-visible {
outline: none; outline: none;
@@ -129,6 +138,11 @@
} }
} }
} }
&[data-size="large"] [data-slot="dialog-container"] {
width: min(calc(100vw - 32px), 800px);
height: min(calc(100vh - 32px), 600px);
}
} }
@keyframes overlayShow { @keyframes overlayShow {

View File

@@ -6,6 +6,7 @@ export interface DialogProps extends ParentProps {
title?: JSXElement title?: JSXElement
description?: JSXElement description?: JSXElement
action?: JSXElement action?: JSXElement
size?: "normal" | "large"
class?: ComponentProps<"div">["class"] class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"] classList?: ComponentProps<"div">["classList"]
fit?: boolean fit?: boolean
@@ -13,10 +14,11 @@ export interface DialogProps extends ParentProps {
export function Dialog(props: DialogProps) { export function Dialog(props: DialogProps) {
return ( return (
<div data-component="dialog" data-fit={props.fit ? true : undefined}> <div data-component="dialog" data-fit={props.fit ? true : undefined} data-size={props.size || "normal"}>
<div data-slot="dialog-container"> <div data-slot="dialog-container">
<Kobalte.Content <Kobalte.Content
data-slot="dialog-content" data-slot="dialog-content"
data-no-header={!props.title && !props.action ? "" : undefined}
classList={{ classList={{
...(props.classList ?? {}), ...(props.classList ?? {}),
[props.class ?? ""]: !!props.class, [props.class ?? ""]: !!props.class,

View File

@@ -215,24 +215,36 @@
height: 100%; height: 100%;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding: 8px;
gap: 4px;
background-color: var(--background-base);
border-right: 1px solid var(--border-weak-base);
&::after { &::after {
width: 100%; display: none;
height: auto;
flex-grow: 1;
border-bottom: none;
border-right: 1px solid var(--border-weak-base);
} }
} }
[data-slot="tabs-trigger-wrapper"] { [data-slot="tabs-trigger-wrapper"] {
width: 100%; width: 100%;
height: auto; height: 32px;
border-bottom: none; border: none;
border-right: 1px solid var(--border-weak-base); border-radius: 8px;
background-color: transparent;
[data-slot="tabs-trigger"] {
padding: 0 8px;
gap: 8px;
justify-content: flex-start;
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
}
&:has([data-selected]) { &:has([data-selected]) {
border-right-color: transparent; background-color: var(--surface-raised-base-hover);
color: var(--text-strong);
} }
} }
@@ -243,32 +255,100 @@
&[data-variant="alt"] { &[data-variant="alt"] {
[data-slot="tabs-list"] { [data-slot="tabs-list"] {
padding-left: 0; padding: 8px;
padding-right: 0; gap: 4px;
padding-top: 24px; border: none;
padding-bottom: 24px;
border-bottom: none;
border-right: 1px solid var(--border-weak-base);
&::after { &::after {
border: none; display: none;
} }
} }
[data-slot="tabs-trigger-wrapper"] { [data-slot="tabs-trigger-wrapper"] {
border-bottom: none; height: 32px;
border-right-width: 2px; border: none;
border-right-style: solid; border-radius: 8px;
border-right-color: transparent;
[data-slot="tabs-trigger"] { [data-slot="tabs-trigger"] {
border-bottom: none; border: none;
padding: 0 8px;
gap: 8px;
justify-content: flex-start;
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
} }
&:has([data-selected]) { &:has([data-selected]) {
border-right-color: var(--icon-strong-base); background-color: var(--surface-raised-base-hover);
color: var(--text-strong);
} }
} }
} }
&[data-variant="settings"] {
[data-slot="tabs-list"] {
width: 180px;
min-width: 180px;
padding: 12px;
gap: 0;
background-color: var(--background-base);
border-right: 1px solid var(--border-weak-base);
&::after {
display: none;
}
}
[data-slot="tabs-section-title"] {
padding: 8px 8px 4px 8px;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
color: var(--text-weak);
}
[data-slot="tabs-trigger-wrapper"] {
height: 32px;
border: none;
border-radius: var(--radius-md);
/* text-14-regular */
font-family: var(--font-family-sans);
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
line-height: var(--line-height-large);
[data-slot="tabs-trigger"] {
border: none;
padding: 0 8px;
gap: 8px;
justify-content: flex-start;
width: 100%;
}
[data-component="icon"] {
color: var(--icon-base);
}
&:hover:not(:disabled) {
background-color: var(--surface-raised-base-hover);
}
&:has([data-selected]) {
background-color: var(--surface-raised-base-hover);
color: var(--text-strong);
[data-component="icon"] {
color: var(--icon-strong-base);
}
}
}
[data-slot="tabs-content"] {
background-color: var(--surface-raised-stronger-non-alpha);
}
}
} }
} }

View File

@@ -1,9 +1,9 @@
import { Tabs as Kobalte } from "@kobalte/core/tabs" import { Tabs as Kobalte } from "@kobalte/core/tabs"
import { Show, splitProps, type JSX } from "solid-js" import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js" import type { ComponentProps, ParentProps, Component } from "solid-js"
export interface TabsProps extends ComponentProps<typeof Kobalte> { export interface TabsProps extends ComponentProps<typeof Kobalte> {
variant?: "normal" | "alt" variant?: "normal" | "alt" | "settings"
orientation?: "horizontal" | "vertical" orientation?: "horizontal" | "vertical"
} }
export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {} export interface TabsListProps extends ComponentProps<typeof Kobalte.List> {}
@@ -106,8 +106,13 @@ function TabsContent(props: ParentProps<TabsContentProps>) {
) )
} }
const TabsSectionTitle: Component<ParentProps> = (props) => {
return <div data-slot="tabs-section-title">{props.children}</div>
}
export const Tabs = Object.assign(TabsRoot, { export const Tabs = Object.assign(TabsRoot, {
List: TabsList, List: TabsList,
Trigger: TabsTrigger, Trigger: TabsTrigger,
Content: TabsContent, Content: TabsContent,
SectionTitle: TabsSectionTitle,
}) })