Skip to content

Commit 141f33d

Browse files
authored
feat: configurable shell selection + desktop settings UI (#20602)
1 parent c4d8a81 commit 141f33d

18 files changed

Lines changed: 721 additions & 157 deletions

File tree

packages/app/src/components/settings-general.tsx

Lines changed: 123 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
1111
import { useParams } from "@solidjs/router"
1212
import { useLanguage } from "@/context/language"
1313
import { usePermission } from "@/context/permission"
14-
import { usePlatform } from "@/context/platform"
14+
import { usePlatform, type DisplayBackend } from "@/context/platform"
15+
import { useGlobalSync } from "@/context/global-sync"
16+
import { useGlobalSDK } from "@/context/global-sdk"
1517
import {
1618
monoDefault,
1719
monoFontFamily,
@@ -40,6 +42,18 @@ type ThemeOption = {
4042
name: string
4143
}
4244

45+
type ShellOption = {
46+
path: string
47+
name: string
48+
acceptable: boolean
49+
}
50+
51+
type ShellSelectOption = {
52+
id: string
53+
value: string
54+
label: string
55+
}
56+
4357
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
4458
// delay the playback by 100ms during quick selection changes and pause existing sounds.
4559
const stopDemoSound = () => {
@@ -75,10 +89,6 @@ export const SettingsGeneral: Component = () => {
7589
const params = useParams()
7690
const settings = useSettings()
7791

78-
onMount(() => {
79-
void theme.loadThemes()
80-
})
81-
8292
const [store, setStore] = createStore({
8393
checking: false,
8494
})
@@ -165,6 +175,70 @@ export const SettingsGeneral: Component = () => {
165175

166176
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
167177

178+
const globalSync = useGlobalSync()
179+
const globalSdk = useGlobalSDK()
180+
181+
const [shells] = createResource(
182+
() =>
183+
globalSdk.client.pty
184+
.shells()
185+
.then((res) => res.data ?? [])
186+
.catch(() => [] as ShellOption[]),
187+
{ initialValue: [] as ShellOption[] },
188+
)
189+
190+
const [displayBackend, { refetch: refetchDisplayBackend }] = createResource(
191+
() => (linux() && platform.getDisplayBackend ? true : false),
192+
() => Promise.resolve(platform.getDisplayBackend?.() ?? null).catch(() => null as DisplayBackend | null),
193+
{ initialValue: null as DisplayBackend | null },
194+
)
195+
196+
onMount(() => {
197+
void theme.loadThemes()
198+
})
199+
200+
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
201+
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
202+
203+
const shellOptions = createMemo<ShellSelectOption[]>(() => {
204+
const list = shells.latest
205+
const current = globalSync.data.config.shell
206+
207+
const nameCounts = new Map<string, number>()
208+
for (const s of list) {
209+
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
210+
}
211+
212+
const options = [
213+
autoOption,
214+
...list.map((s) => {
215+
const ambiguousName = (nameCounts.get(s.name) || 0) > 1
216+
const text = ambiguousName ? s.path : s.name
217+
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
218+
return {
219+
id: s.path,
220+
// Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH.
221+
value: ambiguousName ? s.path : s.name,
222+
label,
223+
}
224+
}),
225+
]
226+
227+
if (current && !options.some((o) => o.value === current)) {
228+
options.push({ id: current, value: current, label: current })
229+
}
230+
231+
return options
232+
})
233+
234+
const onDisplayBackendChange = (checked: boolean) => {
235+
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
236+
if (!update) return
237+
void update.finally(() => {
238+
void refetchDisplayBackend()
239+
})
240+
}
241+
168242
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
169243
{ value: "system", label: language.t("theme.scheme.system") },
170244
{ value: "light", label: language.t("theme.scheme.light") },
@@ -243,6 +317,27 @@ export const SettingsGeneral: Component = () => {
243317
</div>
244318
</SettingsRow>
245319

320+
<SettingsRow
321+
title={language.t("settings.general.row.shell.title")}
322+
description={language.t("settings.general.row.shell.description")}
323+
>
324+
<Select
325+
data-action="settings-shell"
326+
options={shellOptions()}
327+
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
328+
value={(o) => o.id}
329+
label={(o) => o.label}
330+
onSelect={(option) => {
331+
if (!option) return
332+
globalSync.updateConfig({ shell: option.value })
333+
}}
334+
variant="secondary"
335+
size="small"
336+
triggerVariant="settings"
337+
triggerStyle={{ "min-width": "180px" }}
338+
/>
339+
</SettingsRow>
340+
246341
<SettingsRow
247342
title={language.t("settings.general.row.reasoningSummaries.title")}
248343
description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -651,70 +746,32 @@ export const SettingsGeneral: Component = () => {
651746

652747
<SoundsSection />
653748

654-
{/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
655-
{(_) => {
656-
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
657-
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
658-
659-
return (
660-
<div class="flex flex-col gap-1">
661-
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
662-
663-
<SettingsList>
664-
<SettingsRow
665-
title={language.t("settings.desktop.wsl.title")}
666-
description={language.t("settings.desktop.wsl.description")}
667-
>
668-
<div data-action="settings-wsl">
669-
<Switch
670-
checked={enabled() ?? false}
671-
disabled={enabledResource.state === "pending"}
672-
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
673-
/>
674-
</div>
675-
</SettingsRow>
676-
</SettingsList>
677-
</div>
678-
)
679-
}}
680-
</Show>*/}
681-
682749
<UpdatesSection />
683750

684751
<Show when={linux()}>
685-
{(_) => {
686-
const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
687-
const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
688-
689-
const onChange = (checked: boolean) =>
690-
platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
691-
692-
return (
693-
<div class="flex flex-col gap-1">
694-
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
695-
696-
<SettingsList>
697-
<SettingsRow
698-
title={
699-
<div class="flex items-center gap-2">
700-
<span>{language.t("settings.general.row.wayland.title")}</span>
701-
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
702-
<span class="text-text-weak">
703-
<Icon name="help" size="small" />
704-
</span>
705-
</Tooltip>
706-
</div>
707-
}
708-
description={language.t("settings.general.row.wayland.description")}
709-
>
710-
<div data-action="settings-wayland">
711-
<Switch checked={value() === "wayland"} onChange={onChange} />
712-
</div>
713-
</SettingsRow>
714-
</SettingsList>
715-
</div>
716-
)
717-
}}
752+
<div class="flex flex-col gap-1">
753+
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
754+
755+
<SettingsList>
756+
<SettingsRow
757+
title={
758+
<div class="flex items-center gap-2">
759+
<span>{language.t("settings.general.row.wayland.title")}</span>
760+
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
761+
<span class="text-text-weak">
762+
<Icon name="help" size="small" />
763+
</span>
764+
</Tooltip>
765+
</div>
766+
}
767+
description={language.t("settings.general.row.wayland.description")}
768+
>
769+
<div data-action="settings-wayland">
770+
<Switch checked={displayBackend.latest === "wayland"} onChange={onDisplayBackendChange} />
771+
</div>
772+
</SettingsRow>
773+
</SettingsList>
774+
</div>
718775
</Show>
719776

720777
<Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
7878
() =>
7979
retry(() =>
8080
input.globalSDK.global.config.get().then((x) => {
81-
input.setGlobalStore("config", x.data!)
81+
input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
8282
}),
8383
),
8484
]
@@ -245,7 +245,7 @@ export async function bootstrapDirectory(input: {
245245
input.setStore("provider", input.global.provider)
246246
}
247247
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
248-
input.setStore("config", input.global.config)
248+
input.setStore("config", reconcile(input.global.config, { merge: false }))
249249
}
250250
if (loading || input.store.provider.all.length === 0) {
251251
input.setStore("provider_ready", false)
@@ -265,7 +265,8 @@ export async function bootstrapDirectory(input: {
265265
input.queryClient.ensureQueryData(
266266
loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
267267
),
268-
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
268+
() =>
269+
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
269270
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
270271
!seededProject &&
271272
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),

packages/app/src/i18n/en.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,11 @@ export const dict = {
728728

729729
"settings.general.row.language.title": "Language",
730730
"settings.general.row.language.description": "Change the display language for OpenCode",
731+
"settings.general.row.shell.title": "Terminal Shell",
732+
"settings.general.row.shell.description":
733+
"Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
734+
"settings.general.row.shell.autoDefault": "Auto (Default)",
735+
"settings.general.row.shell.terminalOnly": "terminal only",
731736
"settings.general.row.appearance.title": "Appearance",
732737
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
733738
"settings.general.row.colorScheme.title": "Color scheme",

packages/opencode/src/config/config.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ export const Info = Schema.Struct({
9898
$schema: Schema.optional(Schema.String).annotate({
9999
description: "JSON schema reference for configuration validation",
100100
}),
101+
shell: Schema.optional(Schema.String).annotate({
102+
description: "Default shell to use for terminal and bash tool",
103+
}),
101104
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
102105
server: Schema.optional(ConfigServer.Server).annotate({
103106
description: "Server configuration for opencode serve and web commands",
@@ -310,17 +313,21 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
310313
return applyEdits(input, edits)
311314
}
312315

313-
return Object.entries(patch).reduce((result, [key, value]) => {
314-
if (value === undefined) return result
315-
return patchJsonc(result, value, [...path, key])
316-
}, input)
316+
return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
317317
}
318318

319319
function writable(info: Info) {
320320
const { plugin_origins: _plugin_origins, ...next } = info
321321
return next
322322
}
323323

324+
function writableGlobal(info: Info) {
325+
const next = writable(info)
326+
// When a user changes config from a value back to default in the Desktop app, we don't want to leave a blank `"shell": "",` key
327+
if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
328+
return next
329+
}
330+
324331
export const ConfigDirectoryTypoError = NamedError.create(
325332
"ConfigDirectoryTypoError",
326333
z.object({
@@ -749,15 +756,16 @@ export const layer = Layer.effect(
749756
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
750757
const file = globalConfigFile()
751758
const before = (yield* readConfigFile(file)) ?? "{}"
759+
const patch = writableGlobal(config)
752760

753761
let next: Info
754762
if (!file.endsWith(".jsonc")) {
755763
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
756-
const merged = mergeDeep(writable(existing), writable(config))
764+
const merged = mergeDeep(writable(existing), patch)
757765
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
758766
next = merged
759767
} else {
760-
const updated = patchJsonc(before, writable(config))
768+
const updated = patchJsonc(before, patch)
761769
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
762770
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
763771
}

packages/opencode/src/pty/index.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import { InstanceState } from "@/effect"
3+
import { Config } from "@/config"
4+
import { InstanceState, EffectBridge } from "@/effect"
5+
import { lazy } from "@opencode-ai/core/util/lazy"
6+
import { Plugin } from "@/plugin"
47
import { Instance } from "@/project/instance"
8+
import { Shell } from "@/shell/shell"
59
import type { Proc } from "#pty"
610
import { Log } from "../util"
7-
import { lazy } from "@opencode-ai/core/util/lazy"
8-
import { Shell } from "@/shell/shell"
9-
import { Plugin } from "@/plugin"
1011
import { PtyID } from "./schema"
1112
import { Effect, Layer, Context, Schema, Types } from "effect"
1213
import { zod } from "@/util/effect-zod"
1314
import { withStatics } from "@/util/schema"
14-
import { EffectBridge } from "@/effect"
1515

1616
const log = Log.create({ service: "pty" })
1717

@@ -117,8 +117,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pt
117117
export const layer = Layer.effect(
118118
Service,
119119
Effect.gen(function* () {
120+
const config = yield* Config.Service
120121
const bus = yield* Bus.Service
121122
const plugin = yield* Plugin.Service
123+
122124
function teardown(session: Active) {
123125
try {
124126
session.process.kill()
@@ -174,8 +176,9 @@ export const layer = Layer.effect(
174176
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
175177
const s = yield* InstanceState.get(state)
176178
const bridge = yield* EffectBridge.make()
179+
const cfg = yield* config.get()
177180
const id = PtyID.ascending()
178-
const command = input.command || Shell.preferred()
181+
const command = input.command || Shell.preferred(cfg.shell)
179182
const args = input.args || []
180183
if (Shell.login(command)) {
181184
args.push("-l")
@@ -360,6 +363,10 @@ export const layer = Layer.effect(
360363
}),
361364
)
362365

363-
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
366+
export const defaultLayer = layer.pipe(
367+
Layer.provide(Bus.layer),
368+
Layer.provide(Plugin.defaultLayer),
369+
Layer.provide(Config.defaultLayer),
370+
)
364371

365372
export * as Pty from "."

0 commit comments

Comments
 (0)