Просмотр исходного кода

Apply PR #20602: shell as config + desktop settings UI for it

opencode-agent[bot] 10 часов назад
Родитель
Сommit
309e3abc6e

+ 119 - 67
packages/app/src/components/settings-general.tsx

@@ -1,4 +1,4 @@
-import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
+import { Component, Show, createMemo, onMount, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
@@ -11,7 +11,9 @@ import { showToast } from "@opencode-ai/ui/toast"
 import { useParams } from "@solidjs/router"
 import { useLanguage } from "@/context/language"
 import { usePermission } from "@/context/permission"
-import { usePlatform } from "@/context/platform"
+import { usePlatform, type DisplayBackend } from "@/context/platform"
+import { useGlobalSync } from "@/context/global-sync"
+import { useGlobalSDK } from "@/context/global-sdk"
 import {
   monoDefault,
   monoFontFamily,
@@ -40,6 +42,20 @@ type ThemeOption = {
   name: string
 }
 
+type ShellOption = {
+  path: string
+  name: string
+  acceptable: boolean
+}
+
+type ShellSelectOption = {
+  id: string
+  value: string
+  label: string
+}
+
+
+
 // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
 // delay the playback by 100ms during quick selection changes and pause existing sounds.
 const stopDemoSound = () => {
@@ -75,12 +91,10 @@ export const SettingsGeneral: Component = () => {
   const params = useParams()
   const settings = useSettings()
 
-  onMount(() => {
-    void theme.loadThemes()
-  })
-
   const [store, setStore] = createStore({
     checking: false,
+    shells: [] as ShellOption[],
+    displayBackend: null as DisplayBackend | null,
   })
 
   const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
@@ -165,6 +179,61 @@ export const SettingsGeneral: Component = () => {
 
   const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
 
+  const globalSync = useGlobalSync()
+  const globalSdk = useGlobalSDK()
+
+  const syncDisplayBackend = () => {
+    if (!linux() || !platform.getDisplayBackend) return
+    return Promise.resolve(platform.getDisplayBackend()).then((value) => setStore("displayBackend", value)).catch(() => undefined)
+  }
+
+  onMount(() => {
+    void theme.loadThemes()
+    void globalSdk.client.pty.shells().then((res) => setStore("shells", res.data || [])).catch(() => undefined)
+    void syncDisplayBackend()
+  })
+
+  const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") }
+  const currentShell = createMemo(() => globalSync.data.config.shell ?? "")
+
+  const shellOptions = createMemo<ShellSelectOption[]>(() => {
+    const list = store.shells
+    const current = globalSync.data.config.shell
+
+    const nameCounts = new Map<string, number>()
+    for (const s of list) {
+      nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
+    }
+
+    const options = [
+      autoOption,
+      ...list.map((s) => {
+        const dup = (nameCounts.get(s.name) || 0) > 1
+        const text = dup ? s.path : s.name
+        const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})`
+        return {
+          id: s.path,
+          value: dup ? s.path : s.name,
+          label,
+        }
+      }),
+    ]
+
+    if (current && !options.some((o) => o.value === current)) {
+      options.push({ id: current, value: current, label: current })
+    }
+
+    return options
+  })
+
+  const onDisplayBackendChange = (checked: boolean) => {
+    const update = platform.setDisplayBackend?.(checked ? "wayland" : "auto")
+    if (!update) return
+    void update.finally(() => {
+      void syncDisplayBackend()
+    })
+  }
+
   const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
     { value: "system", label: language.t("theme.scheme.system") },
     { value: "light", label: language.t("theme.scheme.light") },
@@ -243,6 +312,27 @@ export const SettingsGeneral: Component = () => {
           </div>
         </SettingsRow>
 
+        <SettingsRow
+          title={language.t("settings.general.row.shell.title")}
+          description={language.t("settings.general.row.shell.description")}
+        >
+          <Select
+            data-action="settings-shell"
+            options={shellOptions()}
+            current={shellOptions().find((o) => o.value === currentShell()) ?? autoOption}
+            value={(o) => o.id}
+            label={(o) => o.label}
+            onSelect={(option) => {
+              if (!option) return
+              globalSync.updateConfig({ shell: option.value })
+            }}
+            variant="secondary"
+            size="small"
+            triggerVariant="settings"
+            triggerStyle={{ "min-width": "180px" }}
+          />
+        </SettingsRow>
+
         <SettingsRow
           title={language.t("settings.general.row.reasoningSummaries.title")}
           description={language.t("settings.general.row.reasoningSummaries.description")}
@@ -651,70 +741,32 @@ export const SettingsGeneral: Component = () => {
 
         <SoundsSection />
 
-        {/*<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
-          {(_) => {
-            const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
-            const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
-
-            return (
-              <div class="flex flex-col gap-1">
-                <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
-
-                <SettingsList>
-                  <SettingsRow
-                    title={language.t("settings.desktop.wsl.title")}
-                    description={language.t("settings.desktop.wsl.description")}
-                  >
-                    <div data-action="settings-wsl">
-                      <Switch
-                        checked={enabled() ?? false}
-                        disabled={enabledResource.state === "pending"}
-                        onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
-                      />
-                    </div>
-                  </SettingsRow>
-                </SettingsList>
-              </div>
-            )
-          }}
-        </Show>*/}
-
         <UpdatesSection />
 
         <Show when={linux()}>
-          {(_) => {
-            const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
-            const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
-
-            const onChange = (checked: boolean) =>
-              platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
-
-            return (
-              <div class="flex flex-col gap-1">
-                <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
-
-                <SettingsList>
-                  <SettingsRow
-                    title={
-                      <div class="flex items-center gap-2">
-                        <span>{language.t("settings.general.row.wayland.title")}</span>
-                        <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
-                          <span class="text-text-weak">
-                            <Icon name="help" size="small" />
-                          </span>
-                        </Tooltip>
-                      </div>
-                    }
-                    description={language.t("settings.general.row.wayland.description")}
-                  >
-                    <div data-action="settings-wayland">
-                      <Switch checked={value() === "wayland"} onChange={onChange} />
-                    </div>
-                  </SettingsRow>
-                </SettingsList>
-              </div>
-            )
-          }}
+          <div class="flex flex-col gap-1">
+            <h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
+
+            <SettingsList>
+              <SettingsRow
+                title={
+                  <div class="flex items-center gap-2">
+                    <span>{language.t("settings.general.row.wayland.title")}</span>
+                    <Tooltip value={language.t("settings.general.row.wayland.tooltip")} placement="top">
+                      <span class="text-text-weak">
+                        <Icon name="help" size="small" />
+                      </span>
+                    </Tooltip>
+                  </div>
+                }
+                description={language.t("settings.general.row.wayland.description")}
+              >
+                <div data-action="settings-wayland">
+                  <Switch checked={store.displayBackend === "wayland"} onChange={onDisplayBackendChange} />
+                </div>
+              </SettingsRow>
+            </SettingsList>
+          </div>
         </Show>
 
         <Show when={desktop() && import.meta.env.VITE_OPENCODE_CHANNEL === "beta"}>

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

@@ -78,7 +78,7 @@ export async function bootstrapGlobal(input: {
     () =>
       retry(() =>
         input.globalSDK.global.config.get().then((x) => {
-          input.setGlobalStore("config", x.data!)
+          input.setGlobalStore("config", reconcile(x.data!, { merge: false }))
         }),
       ),
   ]
@@ -245,7 +245,7 @@ export async function bootstrapDirectory(input: {
     input.setStore("provider", input.global.provider)
   }
   if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
-    input.setStore("config", input.global.config)
+    input.setStore("config", reconcile(input.global.config, { merge: false }))
   }
   if (loading || input.store.provider.all.length === 0) {
     input.setStore("provider_ready", false)
@@ -265,7 +265,7 @@ export async function bootstrapDirectory(input: {
         input.queryClient.ensureQueryData(
           loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
         ),
-      () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
+      () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
       () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
       !seededProject &&
         (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),

+ 4 - 0
packages/app/src/i18n/en.ts

@@ -729,6 +729,10 @@ export const dict = {
 
   "settings.general.row.language.title": "Language",
   "settings.general.row.language.description": "Change the display language for OpenCode",
+  "settings.general.row.shell.title": "Terminal Shell",
+  "settings.general.row.shell.description": "Choose the shell used for your terminal. Compatible shells are also used for agent tool calls.",
+  "settings.general.row.shell.autoDefault": "Auto (Default)",
+  "settings.general.row.shell.terminalOnly": "terminal only",
   "settings.general.row.appearance.title": "Appearance",
   "settings.general.row.appearance.description": "Customise how OpenCode looks on your device",
   "settings.general.row.colorScheme.title": "Color scheme",

+ 14 - 6
packages/opencode/src/config/config.ts

@@ -99,6 +99,9 @@ export const Info = Schema.Struct({
   $schema: Schema.optional(Schema.String).annotate({
     description: "JSON schema reference for configuration validation",
   }),
+  shell: Schema.optional(Schema.String).annotate({
+    description: "Default shell to use for terminal and bash tool",
+  }),
   logLevel: Schema.optional(LogLevelRef).annotate({ description: "Log level" }),
   server: Schema.optional(ConfigServer.Server).annotate({
     description: "Server configuration for opencode serve and web commands",
@@ -311,10 +314,7 @@ function patchJsonc(input: string, patch: unknown, path: string[] = []): string
     return applyEdits(input, edits)
   }
 
-  return Object.entries(patch).reduce((result, [key, value]) => {
-    if (value === undefined) return result
-    return patchJsonc(result, value, [...path, key])
-  }, input)
+  return Object.entries(patch).reduce((result, [key, value]) => patchJsonc(result, value, [...path, key]), input)
 }
 
 function writable(info: Info) {
@@ -322,6 +322,13 @@ function writable(info: Info) {
   return next
 }
 
+function writableGlobal(info: Info) {
+  const next = writable(info)
+  // 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
+  if ("shell" in next && next.shell === "") return { ...next, shell: undefined }
+  return next
+}
+
 export const ConfigDirectoryTypoError = NamedError.create(
   "ConfigDirectoryTypoError",
   z.object({
@@ -754,15 +761,16 @@ export const layer = Layer.effect(
     const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
       const file = globalConfigFile()
       const before = (yield* readConfigFile(file)) ?? "{}"
+      const patch = writableGlobal(config)
 
       let next: Info
       if (!file.endsWith(".jsonc")) {
         const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
-        const merged = mergeDeep(writable(existing), writable(config))
+        const merged = mergeDeep(writable(existing), patch)
         yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
         next = merged
       } else {
-        const updated = patchJsonc(before, writable(config))
+        const updated = patchJsonc(before, patch)
         next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
         yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
       }

+ 14 - 7
packages/opencode/src/pty/index.ts

@@ -1,17 +1,17 @@
 import { BusEvent } from "@/bus/bus-event"
 import { Bus } from "@/bus"
-import { InstanceState } from "@/effect"
+import { Config } from "@/config"
+import { InstanceState, EffectBridge } from "@/effect"
+import { Plugin } from "@/plugin"
 import { Instance } from "@/project/instance"
+import { Shell } from "@/shell/shell"
 import type { Proc } from "#pty"
+import { lazy } from "@/util/lazy"
 import { Log } from "../util"
-import { lazy } from "@opencode-ai/core/util/lazy"
-import { Shell } from "@/shell/shell"
-import { Plugin } from "@/plugin"
 import { PtyID } from "./schema"
 import { Effect, Layer, Context, Schema, Types } from "effect"
 import { zod } from "@/util/effect-zod"
 import { withStatics } from "@/util/schema"
-import { EffectBridge } from "@/effect"
 
 const log = Log.create({ service: "pty" })
 
@@ -117,8 +117,10 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Pt
 export const layer = Layer.effect(
   Service,
   Effect.gen(function* () {
+    const config = yield* Config.Service
     const bus = yield* Bus.Service
     const plugin = yield* Plugin.Service
+
     function teardown(session: Active) {
       try {
         session.process.kill()
@@ -174,8 +176,9 @@ export const layer = Layer.effect(
     const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
       const s = yield* InstanceState.get(state)
       const bridge = yield* EffectBridge.make()
+      const cfg = yield* config.get()
       const id = PtyID.ascending()
-      const command = input.command || Shell.preferred()
+      const command = input.command || Shell.preferred(cfg.shell)
       const args = input.args || []
       if (Shell.login(command)) {
         args.push("-l")
@@ -360,6 +363,10 @@ export const layer = Layer.effect(
   }),
 )
 
-export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Plugin.defaultLayer))
+export const defaultLayer = layer.pipe(
+  Layer.provide(Bus.layer),
+  Layer.provide(Plugin.defaultLayer),
+  Layer.provide(Config.defaultLayer),
+)
 
 export * as Pty from "."

+ 27 - 0
packages/opencode/src/server/routes/instance/pty.ts

@@ -6,14 +6,41 @@ import z from "zod"
 import { AppRuntime } from "@/effect/app-runtime"
 import { Pty } from "@/pty"
 import { PtyID } from "@/pty/schema"
+import { Shell } from "@/shell/shell"
 import { NotFoundError } from "@/storage"
 import { errors } from "../../error"
 import { jsonRequest, runRequest } from "./trace"
 
+const ShellItem = z.object({
+  path: z.string(),
+  name: z.string(),
+  acceptable: z.boolean(),
+})
 const decodePtyID = Schema.decodeUnknownSync(PtyID)
 
 export function PtyRoutes(upgradeWebSocket: UpgradeWebSocket) {
   return new Hono()
+    .get(
+      "/shells",
+      describeRoute({
+        summary: "List available shells",
+        description: "Get a list of available shells on the system.",
+        operationId: "pty.shells",
+        responses: {
+          200: {
+            description: "List of shells",
+            content: {
+              "application/json": {
+                schema: resolver(z.array(ShellItem)),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await Shell.list())
+      },
+    )
     .get(
       "/",
       describeRoute({

+ 8 - 45
packages/opencode/src/session/prompt.ts

@@ -31,7 +31,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import * as Stream from "effect/Stream"
 import { Command } from "../command"
 import { pathToFileURL, fileURLToPath } from "url"
-import { ConfigMarkdown } from "../config"
+import { Config, ConfigMarkdown } from "../config"
 import { SessionSummary } from "./summary"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { SessionProcessor } from "./processor"
@@ -93,6 +93,7 @@ export const layer = Layer.effect(
     const compaction = yield* SessionCompaction.Service
     const plugin = yield* Plugin.Service
     const commands = yield* Command.Service
+    const config = yield* Config.Service
     const permission = yield* Permission.Service
     const fsys = yield* AppFileSystem.Service
     const mcp = yield* MCP.Service
@@ -784,49 +785,10 @@ NOTE: At any point in time through this workflow you should feel free to ask the
       }
       yield* sessions.updatePart(part)
 
-      const sh = Shell.preferred()
-      const shellName = (
-        process.platform === "win32" ? path.win32.basename(sh, ".exe") : path.basename(sh)
-      ).toLowerCase()
+      const cfg = yield* config.get()
+      const sh = Shell.preferred(cfg.shell)
+      const args = Shell.args(sh, input.command)
       const cwd = ctx.directory
-      const invocations: Record<string, { args: string[] }> = {
-        nu: { args: ["-c", input.command] },
-        fish: { args: ["-c", input.command] },
-        zsh: {
-          args: [
-            "-l",
-            "-c",
-            `
-              [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
-              [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
-              cd -- "$1"
-              eval ${JSON.stringify(input.command)}
-            `,
-            "opencode",
-            cwd,
-          ],
-        },
-        bash: {
-          args: [
-            "-l",
-            "-c",
-            `
-              shopt -s expand_aliases
-              [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
-              cd -- "$1"
-              eval ${JSON.stringify(input.command)}
-            `,
-            "opencode",
-            cwd,
-          ],
-        },
-        cmd: { args: ["/c", input.command] },
-        powershell: { args: ["-NoProfile", "-Command", input.command] },
-        pwsh: { args: ["-NoProfile", "-Command", input.command] },
-        "": { args: ["-c", input.command] },
-      }
-
-      const args = (invocations[shellName] ?? invocations[""]).args
       const shellEnv = yield* plugin.trigger(
         "shell.env",
         { cwd, sessionID: input.sessionID, callID: part.callID },
@@ -843,7 +805,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
       let output = ""
       let aborted = false
-
       const finish = Effect.uninterruptible(
         Effect.gen(function* () {
           if (aborted) {
@@ -1589,7 +1550,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
 
       const shellMatches = ConfigMarkdown.shell(template)
       if (shellMatches.length > 0) {
-        const sh = Shell.preferred()
+        const cfg = yield* config.get()
+        const sh = Shell.preferred(cfg.shell)
         const results = yield* Effect.promise(() =>
           Promise.all(
             shellMatches.map(async ([, cmd]) => (await Process.text([cmd], { shell: sh, nothrow: true })).text),
@@ -1690,6 +1652,7 @@ export const defaultLayer = Layer.suspend(() =>
     Layer.provide(ToolRegistry.defaultLayer),
     Layer.provide(Truncate.defaultLayer),
     Layer.provide(Provider.defaultLayer),
+    Layer.provide(Config.defaultLayer),
     Layer.provide(Instruction.defaultLayer),
     Layer.provide(AppFileSystem.defaultLayer),
     Layer.provide(Plugin.defaultLayer),

+ 120 - 20
packages/opencode/src/shell/shell.ts

@@ -7,10 +7,23 @@ import { spawn, type ChildProcess } from "child_process"
 import { setTimeout as sleep } from "node:timers/promises"
 
 const SIGKILL_TIMEOUT_MS = 200
+const META: Record<string, { deny?: boolean; login?: boolean; posix?: boolean; ps?: boolean }> = {
+  bash: { login: true, posix: true },
+  dash: { login: true, posix: true },
+  fish: { deny: true, login: true },
+  ksh: { login: true, posix: true },
+  nu: { deny: true },
+  powershell: { ps: true },
+  pwsh: { ps: true },
+  sh: { login: true, posix: true },
+  zsh: { login: true, posix: true },
+}
 
-const BLACKLIST = new Set(["fish", "nu"])
-const LOGIN = new Set(["bash", "dash", "fish", "ksh", "sh", "zsh"])
-const POSIX = new Set(["bash", "dash", "ksh", "sh", "zsh"])
+export type Item = {
+  path: string
+  name: string
+  acceptable: boolean
+}
 
 export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
   const pid = proc.pid
@@ -53,19 +66,49 @@ function full(file: string) {
   return which(shell) || shell
 }
 
-function pick() {
-  const pwsh = which("pwsh.exe")
-  if (pwsh) return pwsh
-  const powershell = which("powershell.exe")
-  if (powershell) return powershell
+function meta(file: string) {
+  return META[name(file)]
+}
+
+function ok(file: string) {
+  return meta(file)?.deny !== true
+}
+
+function rooted(file: string) {
+  return path.isAbsolute(Filesystem.windowsPath(file))
+}
+
+function resolve(file: string) {
+  const shell = full(file)
+  if (rooted(shell)) {
+    if (Filesystem.stat(shell)?.isFile()) return shell
+    return
+  }
+  return which(shell) ?? undefined
+}
+
+function win() {
+  return Array.from(
+    new Set(
+      [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
+        .filter((item): item is string => Boolean(item))
+        .map(full),
+    ),
+  )
+}
+
+async function unix() {
+  const text = await Filesystem.readText("/etc/shells").catch(() => "")
+  if (text) return Array.from(new Set(text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))))
+  return ["/bin/bash", "/bin/zsh", "/bin/sh"]
 }
 
 function select(file: string | undefined, opts?: { acceptable?: boolean }) {
-  if (file && (!opts?.acceptable || !BLACKLIST.has(name(file)))) return full(file)
-  if (process.platform === "win32") {
-    const shell = pick()
+  if (file && (!opts?.acceptable || ok(file))) {
+    const shell = resolve(file)
     if (shell) return shell
   }
+  if (process.platform === "win32") return win()[0]!
   return fallback()
 }
 
@@ -79,11 +122,6 @@ export function gitbash() {
 }
 
 function fallback() {
-  if (process.platform === "win32") {
-    const file = gitbash()
-    if (file) return file
-    return process.env.COMSPEC || "cmd.exe"
-  }
   if (process.platform === "darwin") return "/bin/zsh"
   const bash = which("bash")
   if (bash) return bash
@@ -96,15 +134,77 @@ export function name(file: string) {
 }
 
 export function login(file: string) {
-  return LOGIN.has(name(file))
+  return meta(file)?.login === true
 }
 
 export function posix(file: string) {
-  return POSIX.has(name(file))
+  return meta(file)?.posix === true
+}
+
+export function ps(file: string) {
+  return meta(file)?.ps === true
 }
 
-export const preferred = lazy(() => select(process.env.SHELL))
+function info(file: string): Item {
+  return {
+    path: full(file),
+    name: name(file),
+    acceptable: ok(file),
+  }
+}
 
-export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
+export function args(file: string, command: string) {
+  const n = name(file)
+  if (n === "nu" || n === "fish") return ["-c", command]
+  if (n === "zsh") {
+    return [
+      "-l",
+      "-c",
+      `
+        __oc_cwd=$PWD
+        [[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
+        [[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
+        cd "$__oc_cwd"
+        eval ${JSON.stringify(command)}
+      `,
+    ]
+  }
+  if (n === "bash") {
+    return [
+      "-l",
+      "-c",
+      `
+        __oc_cwd=$PWD
+        shopt -s expand_aliases
+        [[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
+        cd "$__oc_cwd"
+        eval ${JSON.stringify(command)}
+      `,
+    ]
+  }
+  if (n === "cmd") return ["/c", command]
+  if (ps(file)) return ["-NoProfile", "-Command", command]
+  return ["-c", command]
+}
+
+const defaultPreferred = lazy(() => select(process.env.SHELL))
+const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
+
+export function preferred(configShell?: string) {
+  if (configShell) return select(configShell)
+  return defaultPreferred()
+}
+preferred.reset = () => defaultPreferred.reset()
+
+export function acceptable(configShell?: string) {
+  if (configShell) return select(configShell, { acceptable: true })
+  return defaultAcceptable()
+}
+acceptable.reset = () => defaultAcceptable.reset()
+
+export async function list(): Promise<Item[]> {
+  const shells = process.platform === "win32" ? win() : await unix()
+  return shells.filter((s) => resolve(s)).map(info)
+}
 
 export * as Shell from "./shell"

+ 8 - 9
packages/opencode/src/tool/shell.ts

@@ -10,6 +10,7 @@ import { Language, type Node } from "web-tree-sitter"
 
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { fileURLToPath } from "url"
+import { Config } from "@/config"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { Shell } from "@/shell/shell"
 import { ShellKind, ShellToolID } from "./shell/id"
@@ -25,7 +26,6 @@ export { Parameters } from "./shell/prompt"
 
 const MAX_METADATA_LENGTH = 30_000
 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
-const PS = new Set(["powershell", "pwsh"])
 const CWD = new Set(["cd", "push-location", "set-location"])
 const FILES = new Set([
   ...CWD,
@@ -267,8 +267,8 @@ const ask = Effect.fn("ShellTool.ask")(function* (ctx: Tool.Context, scan: Scan)
   })
 })
 
-function cmd(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
-  if (process.platform === "win32" && PS.has(name)) {
+function cmd(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
+  if (process.platform === "win32" && Shell.ps(shell)) {
     return ChildProcess.make(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
       cwd,
       env,
@@ -285,7 +285,6 @@ function cmd(shell: string, name: string, command: string, cwd: string, env: Nod
     detached: process.platform !== "win32",
   })
 }
-
 const parser = lazy(async () => {
   const { Parser } = await import("web-tree-sitter")
   const { default: treeWasm } = await import("web-tree-sitter/tree-sitter.wasm" as string, {
@@ -316,6 +315,7 @@ const parser = lazy(async () => {
 export const ShellTool = Tool.define(
   ShellToolID.id,
   Effect.gen(function* () {
+    const config = yield* Config.Service
     const spawner = yield* ChildProcessSpawner
     const fs = yield* AppFileSystem.Service
     const trunc = yield* Truncate.Service
@@ -397,7 +397,6 @@ export const ShellTool = Tool.define(
     const run = Effect.fn("ShellTool.run")(function* (
       input: {
         shell: string
-        name: string
         command: string
         cwd: string
         env: NodeJS.ProcessEnv
@@ -427,7 +426,7 @@ export const ShellTool = Tool.define(
 
       const code: number | null = yield* Effect.scoped(
         Effect.gen(function* () {
-          const handle = yield* spawner.spawn(cmd(input.shell, input.name, input.command, input.cwd, input.env))
+          const handle = yield* spawner.spawn(cmd(input.shell, input.command, input.cwd, input.env))
 
           yield* Effect.forkScoped(
             Stream.runForEach(Stream.decodeText(handle.all), (chunk) => {
@@ -556,7 +555,8 @@ export const ShellTool = Tool.define(
 
     return () =>
       Effect.gen(function* () {
-        const shell = Shell.acceptable()
+        const cfg = yield* config.get()
+        const shell = Shell.acceptable(cfg.shell)
         const name = Shell.name(shell)
         const limits = yield* trunc.limits()
         const prompt = ShellPrompt.render(name, process.platform, limits)
@@ -574,7 +574,7 @@ export const ShellTool = Tool.define(
                 throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`)
               }
               const timeout = params.timeout ?? DEFAULT_TIMEOUT
-              const ps = PS.has(name)
+              const ps = Shell.ps(shell)
               const root = yield* parse(params.command, ps)
               const scan = yield* collect(root, cwd, ps, shell)
               if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
@@ -583,7 +583,6 @@ export const ShellTool = Tool.define(
               return yield* run(
                 {
                   shell,
-                  name,
                   command: params.command,
                   cwd,
                   env: yield* shellEnv(ctx, cwd),

+ 98 - 0
packages/opencode/test/config/config.test.ts

@@ -55,6 +55,8 @@ const it = testEffect(layer)
 const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer)))
 const save = (config: Config.Info) =>
   Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer)))
+const saveGlobal = (config: Config.Info) =>
+  Effect.runPromise(Config.Service.use((svc) => svc.updateGlobal(config)).pipe(Effect.scoped, Effect.provide(layer)))
 const clear = (wait = false) =>
   Effect.runPromise(Config.Service.use((svc) => svc.invalidate(wait)).pipe(Effect.scoped, Effect.provide(layer)))
 const listDirs = () =>
@@ -142,6 +144,102 @@ test("loads JSON config file", async () => {
   })
 })
 
+test("loads shell config field", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        shell: "bash",
+      })
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await load()
+      expect(config.shell).toBe("bash")
+    },
+  })
+})
+
+test("updates config and preserves empty shell sentinel", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        shell: "bash",
+      }, "config.json")
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await save({ shell: "" })
+
+      const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json"))
+      expect(writtenConfig.shell).toBe("")
+    },
+  })
+})
+
+test("updates global config and omits empty shell key in json", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        shell: "bash",
+      })
+    },
+  })
+
+  const prev = Global.Path.config
+  ;(Global.Path as { config: string }).config = tmp.path
+  await clear(true)
+
+  try {
+    await saveGlobal({ shell: "" })
+
+    const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json"))
+    expect("shell" in writtenConfig).toBe(false)
+  } finally {
+    ;(Global.Path as { config: string }).config = prev
+    await clear(true)
+  }
+})
+
+test("updates global config and omits empty shell key in jsonc", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await Filesystem.write(
+        path.join(dir, "opencode.jsonc"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          shell: "bash",
+          model: "test/model",
+        }),
+      )
+    },
+  })
+
+  const prev = Global.Path.config
+  ;(Global.Path as { config: string }).config = tmp.path
+  await clear(true)
+
+  try {
+    await saveGlobal({ shell: "" })
+
+    const file = path.join(tmp.path, "opencode.jsonc")
+    const writtenConfig = await Filesystem.readText(file)
+    const parsed = ConfigParse.schema(Config.Info.zod, ConfigParse.jsonc(writtenConfig, file), file)
+    expect(writtenConfig).not.toContain('"shell"')
+    expect(parsed.shell).toBeUndefined()
+    expect(parsed.model).toBe("test/model")
+  } finally {
+    ;(Global.Path as { config: string }).config = prev
+    await clear(true)
+  }
+})
+
 test("loads formatter boolean config", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 51 - 0
packages/opencode/test/pty/pty-shell.test.ts

@@ -8,6 +8,20 @@ import { tmpdir } from "../fixture/fixture"
 
 Shell.preferred.reset()
 
+const withShell = async (shell: string | undefined, fn: () => Promise<void>) => {
+  const prev = process.env.SHELL
+  if (shell === undefined) delete process.env.SHELL
+  else process.env.SHELL = shell
+  Shell.preferred.reset()
+  try {
+    await fn()
+  } finally {
+    if (prev === undefined) delete process.env.SHELL
+    else process.env.SHELL = prev
+    Shell.preferred.reset()
+  }
+}
+
 describe("pty shell args", () => {
   if (process.platform !== "win32") return
 
@@ -67,3 +81,40 @@ describe("pty shell args", () => {
     )
   }
 })
+
+describe("pty configured shell", () => {
+  test(
+    "uses configured shell for default PTY command",
+    async () => {
+      const configured = process.platform === "win32" ? Bun.which("pwsh") || Bun.which("powershell") : Bun.which("bash")
+      if (!configured) return
+
+      await withShell(process.platform === "win32" ? process.env.COMSPEC || "cmd.exe" : "/bin/sh", async () => {
+        await using dir = await tmpdir({
+          config: { shell: Shell.name(configured) },
+        })
+        await Instance.provide({
+          directory: dir.path,
+          fn: () =>
+            AppRuntime.runPromise(
+              Effect.gen(function* () {
+                const pty = yield* Pty.Service
+                const info = yield* pty.create({ title: "configured" })
+                try {
+                  if (process.platform === "win32") {
+                    expect(info.command.toLowerCase()).toBe(configured.toLowerCase())
+                  } else {
+                    expect(info.command).toBe(configured)
+                  }
+                  expect(info.args).toEqual(process.platform === "win32" ? [] : ["-l"])
+                } finally {
+                  yield* pty.remove(info.id)
+                }
+              }),
+            ),
+        })
+      })
+    },
+    { timeout: 30000 },
+  )
+})

+ 67 - 0
packages/opencode/test/session/prompt.test.ts

@@ -316,9 +316,11 @@ const addSubtask = (sessionID: SessionID, messageID: MessageID, model = ref) =>
   })
 
 const boot = Effect.fn("test.boot")(function* (input?: { title?: string }) {
+  const config = yield* Config.Service
   const prompt = yield* SessionPrompt.Service
   const run = yield* SessionRunState.Service
   const sessions = yield* Session.Service
+  yield* config.get()
   const chat = yield* sessions.create(input ?? { title: "Pinned" })
   return { prompt, run, sessions, chat }
 })
@@ -1102,6 +1104,32 @@ unix("shell commands can change directory after startup", () =>
   ),
 )
 
+unix(
+  "shell uses configured shell over env shell",
+  () =>
+    withSh(() =>
+      provideTmpdirInstance(
+        (_dir) =>
+          Effect.gen(function* () {
+            if (!Bun.which("bash")) return
+
+            const { prompt, chat } = yield* boot()
+            const result = yield* prompt.shell({
+              sessionID: chat.id,
+              agent: "build",
+              command: "[[ 1 -eq 1 ]] && printf configured",
+            })
+
+            const tool = completedTool(result.parts)
+            if (!tool) return
+            expect(tool.state.output).toContain("configured")
+          }),
+        { git: true, config: { ...cfg, shell: "bash" } },
+      ),
+    ),
+  30_000,
+)
+
 unix("shell lists files from the project directory", () =>
   provideTmpdirInstance(
     (dir) =>
@@ -1263,6 +1291,45 @@ it.live(
   3_000,
 )
 
+unix(
+  "command ! expansion uses configured shell over env shell",
+  () =>
+    withSh(() =>
+      provideTmpdirServer(
+        ({ llm }) =>
+          Effect.gen(function* () {
+            if (!Bun.which("bash")) return
+
+            const { prompt, chat } = yield* boot()
+            yield* llm.text("done")
+
+            const result = yield* prompt.command({
+              sessionID: chat.id,
+              command: "probe",
+              arguments: "",
+            })
+
+            expect(result.info.role).toBe("assistant")
+            const inputs = yield* llm.inputs
+            expect(JSON.stringify(inputs.at(-1)?.messages)).toContain("configured")
+          }),
+        {
+          git: true,
+          config: (url) => ({
+            ...providerCfg(url),
+            shell: "bash",
+            command: {
+              probe: {
+                template: "Probe: !`[[ 1 -eq 1 ]] && printf configured`",
+              },
+            },
+          }),
+        },
+      ),
+    ),
+  30_000,
+)
+
 unix(
   "cancel interrupts shell and resolves cleanly",
   () =>

+ 16 - 1
packages/opencode/test/shell/shell.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
 import path from "path"
 import { Shell } from "../../src/shell/shell"
 import { Filesystem } from "../../src/util"
+import { which } from "../../src/util/which"
 
 const withShell = async (shell: string | undefined, fn: () => void | Promise<void>) => {
   const prev = process.env.SHELL
@@ -39,6 +40,20 @@ describe("shell", () => {
     expect(Shell.posix("C:/tools/pwsh.exe")).toBe(false)
   })
 
+  test("falls back when configured shell cannot be resolved", async () => {
+    await withShell(undefined, async () => {
+      const preferred = Shell.preferred()
+      const acceptable = Shell.acceptable()
+      expect(Shell.preferred("opencode-missing-shell")).toBe(preferred)
+      expect(Shell.acceptable("opencode-missing-shell")).toBe(acceptable)
+    })
+  })
+
+  test("falls back for terminal-only acceptable shells", () => {
+    expect(Shell.name(Shell.acceptable("fish"))).not.toBe("fish")
+    expect(Shell.name(Shell.acceptable("nu"))).not.toBe("nu")
+  })
+
   if (process.platform === "win32") {
     test("rejects blacklisted shells case-insensitively", async () => {
       await withShell("NU.EXE", async () => {
@@ -63,7 +78,7 @@ describe("shell", () => {
     })
 
     test("resolves bare PowerShell shells", async () => {
-      const shell = Bun.which("pwsh") || Bun.which("powershell")
+      const shell = which("pwsh") || which("powershell")
       if (!shell) return
       await withShell(path.win32.basename(shell), async () => {
         expect(Shell.preferred()).toBe(shell)

+ 29 - 0
packages/opencode/test/tool/shell.test.ts

@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
 import { Effect, Layer, ManagedRuntime } from "effect"
 import os from "os"
 import path from "path"
+import { Config } from "../../src/config"
 import { Shell } from "../../src/shell/shell"
 import { ShellToolID } from "../../src/tool/shell/id"
 import { ShellTool } from "../../src/tool/shell"
@@ -22,6 +23,7 @@ const runtime = ManagedRuntime.make(
     AppFileSystem.defaultLayer,
     Plugin.defaultLayer,
     Truncate.defaultLayer,
+    Config.defaultLayer,
     Agent.defaultLayer,
   ),
 )
@@ -158,6 +160,33 @@ describe("tool.shell", () => {
       },
     })
   })
+
+  test("falls back from terminal-only configured shell", async () => {
+    await using tmp = await tmpdir({
+      config: { shell: "fish" },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await initBash()
+        const fallback = Shell.name(Shell.acceptable("fish"))
+        expect(fallback).not.toBe("fish")
+        expect(bash.description).toContain(fallback)
+
+        const result = await Effect.runPromise(
+          bash.execute(
+            {
+              command: "echo fallback",
+              description: "Echo fallback text",
+            },
+            ctx,
+          ),
+        )
+        expect(result.metadata.exit).toBe(0)
+        expect(result.output).toContain("fallback")
+      },
+    })
+  })
 })
 
 describe("tool.shell permissions", () => {

+ 31 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -105,6 +105,7 @@ import type {
   PtyListResponses,
   PtyRemoveErrors,
   PtyRemoveResponses,
+  PtyShellsResponses,
   PtyUpdateErrors,
   PtyUpdateResponses,
   QuestionAnswer,
@@ -1080,6 +1081,36 @@ export class Project extends HeyApiClient {
 }
 
 export class Pty extends HeyApiClient {
+  /**
+   * List available shells
+   *
+   * Get a list of available shells on the system.
+   */
+  public shells<ThrowOnError extends boolean = false>(
+    parameters?: {
+      directory?: string
+      workspace?: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "query", key: "directory" },
+            { in: "query", key: "workspace" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).get<PtyShellsResponses, unknown, ThrowOnError>({
+      url: "/pty/shells",
+      ...options,
+      ...params,
+    })
+  }
+
   /**
    * List PTY sessions
    *

+ 27 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1470,6 +1470,10 @@ export type Config = {
    * JSON schema reference for configuration validation
    */
   $schema?: string
+  /**
+   * Default shell to use for terminal and bash tool
+   */
+  shell?: string
   logLevel?: LogLevel
   server?: ServerConfig
   /**
@@ -2694,6 +2698,29 @@ export type ProjectUpdateResponses = {
 
 export type ProjectUpdateResponse = ProjectUpdateResponses[keyof ProjectUpdateResponses]
 
+export type PtyShellsData = {
+  body?: never
+  path?: never
+  query?: {
+    directory?: string
+    workspace?: string
+  }
+  url: "/pty/shells"
+}
+
+export type PtyShellsResponses = {
+  /**
+   * List of shells
+   */
+  200: Array<{
+    path: string
+    name: string
+    acceptable: boolean
+  }>
+}
+
+export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses]
+
 export type PtyListData = {
   body?: never
   path?: never

+ 15 - 0
packages/web/src/content/docs/config.mdx

@@ -312,6 +312,21 @@ Available options:
 
 ---
 
+### Shell
+
+You can configure the shell used for the interactive terminal using the `shell` option. Compatible shells are also used for agent tool calls.
+
+```json title="opencode.json"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "shell": "pwsh"
+}
+```
+
+If not specified, OpenCode will automatically discover and use a sensible default based on your operating system (e.g. `pwsh` or `cmd.exe` on Windows, `/bin/zsh` or `/bin/bash` on macOS/Linux). You can provide an absolute path or a short name.
+
+---
+
 ### Tools
 
 You can manage the tools an LLM can use through the `tools` option.