Skip to content

Commit b8489e1

Browse files
Apply PR #20602: shell as config + desktop settings UI for it
2 parents b69d27e + 7ca307d commit b8489e1

17 files changed

Lines changed: 651 additions & 158 deletions

File tree

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

Lines changed: 119 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
1+
import { Component, Show, createMemo, onMount, type JSX } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { Button } from "@opencode-ai/ui/button"
44
import { Icon } from "@opencode-ai/ui/icon"
@@ -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,20 @@ 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+
57+
58+
4359
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
4460
// delay the playback by 100ms during quick selection changes and pause existing sounds.
4561
const stopDemoSound = () => {
@@ -75,12 +91,10 @@ export const SettingsGeneral: Component = () => {
7591
const params = useParams()
7692
const settings = useSettings()
7793

78-
onMount(() => {
79-
void theme.loadThemes()
80-
})
81-
8294
const [store, setStore] = createStore({
8395
checking: false,
96+
shells: [] as ShellOption[],
97+
displayBackend: null as DisplayBackend | null,
8498
})
8599

86100
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
@@ -165,6 +179,61 @@ export const SettingsGeneral: Component = () => {
165179

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

182+
const globalSync = useGlobalSync()
183+
const globalSdk = useGlobalSDK()
184+
185+
const syncDisplayBackend = () => {
186+
if (!linux() || !platform.getDisplayBackend) return
187+
return Promise.resolve(platform.getDisplayBackend()).then((value) => setStore("displayBackend", value)).catch(() => undefined)
188+
}
189+
190+
onMount(() => {
191+
void theme.loadThemes()
192+
void globalSdk.client.pty.shells().then((res) => setStore("shells", res.data || [])).catch(() => undefined)
193+
void syncDisplayBackend()
194+
})
195+
196+
const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
197+
const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
198+
199+
const shellOptions = createMemo<ShellSelectOption[]>(() => {
200+
const list = store.shells
201+
const current = globalSync.data.config.shell
202+
203+
const nameCounts = new Map<string, number>()
204+
for (const s of list) {
205+
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
206+
}
207+
208+
const options = [
209+
autoOption,
210+
...list.map((s) => {
211+
const dup = (nameCounts.get(s.name) || 0) > 1
212+
const text = dup ? s.path : s.name
213+
const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
214+
return {
215+
id: s.path,
216+
value: dup ? s.path : s.name,
217+
label,
218+
}
219+
}),
220+
]
221+
222+
if (current && !options.some((o) => o.value === current)) {
223+
options.push({ id: current, value: current, label: current })
224+
}
225+
226+
return options
227+
})
228+
229+
const onDisplayBackendChange = (checked: boolean) => {
230+
const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
231+
if (!update) return
232+
void update.finally(() => {
233+
void syncDisplayBackend()
234+
})
235+
}
236+
168237
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
169238
{ value: "system", label: language.t("theme.scheme.system") },
170239
{ value: "light", label: language.t("theme.scheme.light") },
@@ -243,6 +312,27 @@ export const SettingsGeneral: Component = () => {
243312
</div>
244313
</SettingsRow>
245314

315+
<SettingsRow
316+
title={language.t("settings.general.row.shell.title")}
317+
description={language.t("settings.general.row.shell.description")}
318+
>
319+
<Select
320+
data-action="settings-shell"
321+
options={shellOptions()}
322+
current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
323+
value={(o) => o.id}
324+
label={(o) => o.label}
325+
onSelect={(option) => {
326+
if (!option) return
327+
globalSync.updateConfig({ shell: option.value })
328+
}}
329+
variant="secondary"
330+
size="small"
331+
triggerVariant="settings"
332+
triggerStyle={{ "min-width": "180px" }}
333+
/>
334+
</SettingsRow>
335+
246336
<SettingsRow
247337
title={language.t("settings.general.row.reasoningSummaries.title")}
248338
description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -651,70 +741,32 @@ export const SettingsGeneral: Component = () => {
651741

652742
<SoundsSection />
653743

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-
682744
<UpdatesSection />
683745

684746
<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-
}}
747+
<div class="flex flex-col gap-1">
748+
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
749+
750+
<SettingsList>
751+
<SettingsRow
752+
title={
753+
<div class="flex items-center gap-2">
754+
<span>{language.t("settings.general.row.wayland.title")}</span>
755+
<Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
756+
<span class="text-text-weak">
757+
<Icon name="help" size="small" />
758+
</span>
759+
</Tooltip>
760+
</div>
761+
}
762+
description={language.t("settings.general.row.wayland.description")}
763+
>
764+
<div data-action="settings-wayland">
765+
<Switch checked={store.displayBackend === "wayland"} onChange={onDisplayBackendChange} />
766+
</div>
767+
</SettingsRow>
768+
</SettingsList>
769+
</div>
718770
</Show>
719771

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

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

Lines changed: 3 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,7 @@ 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+
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
269269
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
270270
!seededProject &&
271271
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),

packages/app/src/i18n/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,10 @@ export const dict = {
729729

730730
"settings.general.row.language.title": "Language",
731731
"settings.general.row.language.description": "Change the display language for OpenCode",
732+
"settings.general.row.shell.title": "Terminal Shell",
733+
"settings.general.row.shell.description": "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",
732736
"settings.general.row.appearance.title": "Appearance",
733737
"settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
734738
"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
@@ -99,6 +99,9 @@ export const Info = Schema.Struct({
9999
$schema: Schema.optional(Schema.String).annotate({
100100
description: "JSON schema reference for configuration validation",
101101
}),
102+
shell: Schema.optional(Schema.String).annotate({
103+
description: "Default shell to use for terminal and bash tool",
104+
}),
102105
logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
103106
server: Schema.optional(ConfigServer.Server).annotate({
104107
description: "Server configuration for opencode serve and web commands",
@@ -311,17 +314,21 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
311314
return applyEdits(input, edits)
312315
}
313316

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

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

325+
function writableGlobal(info: Info) {
326+
const next = writable(info)
327+
// 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
328+
if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
329+
return next
330+
}
331+
325332
export const ConfigDirectoryTypoError = NamedError.create(
326333
"ConfigDirectoryTypoError",
327334
z.object({
@@ -754,15 +761,16 @@ export const layer = Layer.effect(
754761
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
755762
const file = globalConfigFile()
756763
const before = (yield* readConfigFile(file)) ?? "{}"
764+
const patch = writableGlobal(config)
757765

758766
let next: Info
759767
if (!file.endsWith(".jsonc")) {
760768
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
761-
const merged = mergeDeep(writable(existing), writable(config))
769+
const merged = mergeDeep(writable(existing), patch)
762770
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
763771
next = merged
764772
} else {
765-
const updated = patchJsonc(before, writable(config))
773+
const updated = patchJsonc(before, patch)
766774
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
767775
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
768776
}

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 { Plugin } from "@/plugin"
46
import { Instance } from "@/project/instance"
7+
import { Shell } from "@/shell/shell"
58
import type { Proc } from "#pty"
9+
import { lazy } from "@/util/lazy"
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)