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

This commit is contained in:
opencode-agent[bot] 2026-04-25 17:31:07 +00:00
commit edee841639
17 changed files with 652 additions and 159 deletions

View file

@ -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)
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.display")}</h3>
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>
)
}}
<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"}>

View file

@ -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))),

View file

@ -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",

View file

@ -101,6 +101,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",
@ -313,10 +316,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) {
@ -324,6 +324,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({
@ -756,15 +763,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.schema(Info.zod, 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.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

View file

@ -1,17 +1,17 @@
import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { InstanceState } from "@/effect"
import { Instance } from "@/project/instance"
import type { Proc } from "#pty"
import { Log } from "../util"
import { lazy } from "@opencode-ai/core/util/lazy"
import { Shell } from "@/shell/shell"
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 { 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 "."

View file

@ -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({

View file

@ -31,7 +31,7 @@ import * as CrossSpawnSpawner from "@/effect/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),

View file

@ -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 const preferred = lazy(() => select(process.env.SHELL))
export function ps(file: string) {
return meta(file)?.ps === true
}
export const acceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
function info(file: string): Item {
return {
path: full(file),
name: name(file),
acceptable: ok(file),
}
}
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"

View file

@ -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 "@/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),

View file

@ -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) => {

View file

@ -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 },
)
})

View file

@ -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",
() =>

View file

@ -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)

View file

@ -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", () => {

View file

@ -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
*

View file

@ -1472,6 +1472,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
/**
@ -2696,6 +2700,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

View file

@ -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.