refactor: centralize shell selection metadata

Keep terminal, prompt, and background shell handling in sync while letting the UI reflect discovered shell capabilities without re-parsing paths.
This commit is contained in:
LukeParkerDev 2026-04-02 14:02:56 +10:00
parent 947a26f4df
commit 3a81b4b257
8 changed files with 166 additions and 101 deletions

View file

@ -36,6 +36,12 @@ type ThemeOption = {
name: string
}
type ShellOption = {
path: string
name: string
acceptable: boolean
}
// 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 = () => {
@ -138,41 +144,33 @@ export const SettingsGeneral: Component = () => {
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const [shells] = createResource(() => globalSdk.client.pty.shells().then((res) => res.data || []))
const [shells] = createResource<ShellOption[]>(() => globalSdk.client.pty.shells().then((res) => res.data || []))
const auto = { value: "", label: "Auto (Default)" }
const shellOptions = createMemo(() => {
const list = shells() || []
const current = globalSync.data.config.shell
const getShortName = (p: string) => {
const parts = p.split(/[/\\]/)
let name = parts[parts.length - 1]
const dotIndex = name.lastIndexOf(".")
if (dotIndex > 0) {
name = name.slice(0, dotIndex)
}
return name
}
const nameCounts = new Map<string, number>()
for (const s of list) {
const name = getShortName(s)
nameCounts.set(name, (nameCounts.get(name) || 0) + 1)
nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1)
}
const options = list.map((s) => {
const name = getShortName(s)
const isDuplicate = (nameCounts.get(name) || 0) > 1
const label = isDuplicate ? s : name
const value = isDuplicate ? s : name
return { value, label }
})
options.unshift({ value: "auto", label: "Auto (Default)" })
const options = [
auto,
...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")})`
const value = dup ? s.path : s.name
return { value, label }
}),
]
if (current && !options.some((o) => o.value === current)) {
options.push({ value: current, label: current })
}
return options
})
@ -256,12 +254,12 @@ export const SettingsGeneral: Component = () => {
<Select
data-action="settings-shell"
options={shellOptions()}
current={shellOptions().find((o) => o.value === globalSync.data.config.shell) ?? shellOptions()[0]}
current={shellOptions().find((o) => o.value === globalSync.data.config.shell) ?? auto}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => {
const value = option?.value === "auto" ? "" : option?.value
globalSync.updateConfig({ shell: value })
if (!option) return
globalSync.updateConfig({ shell: option.value })
}}
variant="secondary"
size="small"

View file

@ -726,6 +726,7 @@ export const dict = {
"settings.general.row.shell.title": "Terminal Shell",
"settings.general.row.shell.description":
"Choose the default shell used for your terminal and the agent's background processes.",
"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

@ -9,6 +9,12 @@ import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Shell } from "@/shell/shell"
const ShellItem = z.object({
path: z.string(),
name: z.string(),
acceptable: z.boolean(),
})
export const PtyRoutes = lazy(() =>
new Hono()
.get(
@ -22,14 +28,14 @@ export const PtyRoutes = lazy(() =>
description: "List of shells",
content: {
"application/json": {
schema: resolver(z.array(z.string())),
schema: resolver(z.array(ShellItem)),
},
},
},
},
}),
async (c) => {
return c.json(await Shell.available())
return c.json(await Shell.list())
},
)
.get(

View file

@ -50,6 +50,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Config } from "@/config/config"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@ -803,46 +804,9 @@ 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 invocations: Record<string, { args: string[] }> = {
nu: { args: ["-c", input.command] },
fish: { args: ["-c", input.command] },
zsh: {
args: [
"-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(input.command)}
`,
],
},
bash: {
args: [
"-l",
"-c",
`
__oc_cwd=$PWD
shopt -s expand_aliases
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
cd "$__oc_cwd"
eval ${JSON.stringify(input.command)}
`,
],
},
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 cfg = yield* Effect.promise(() => Config.get())
const sh = Shell.preferred(cfg.shell)
const args = Shell.args(sh, input.command)
const cwd = ctx.directory
const shellEnv = yield* plugin.trigger(
"shell.env",
@ -1609,7 +1573,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* Effect.promise(() => 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),

View file

@ -7,11 +7,24 @@ 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 },
}
export namespace Shell {
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
@ -54,17 +67,52 @@ export namespace Shell {
return Bun.which(shell) || shell
}
function pick() {
const pwsh = Bun.which("pwsh")
if (pwsh) return pwsh
const powershell = Bun.which("powershell")
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(
[Bun.which("pwsh"), Bun.which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"]
.filter((item): item is string => Boolean(item))
.map(full),
),
)
}
async function unix() {
const file = Bun.file("/etc/shells")
if (await file.exists()) {
return Array.from(new Set((await file.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 (file && (!opts?.acceptable || ok(file))) {
const shell = resolve(file)
if (shell) return shell
}
if (process.platform === "win32") {
const shell = pick()
const shell = win()[0]
if (shell) return shell
}
return fallback()
@ -81,7 +129,7 @@ export namespace Shell {
function fallback() {
if (process.platform === "win32") {
const file = gitbash()
const file = win()[0]
if (file) return file
return process.env.COMSPEC || "cmd.exe"
}
@ -97,11 +145,57 @@ export namespace Shell {
}
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 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))
@ -119,17 +213,8 @@ export namespace Shell {
}
acceptable.reset = () => defaultAcceptable.reset()
export async function available(): Promise<string[]> {
if (process.platform === "win32") {
return [gitbash(), Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || "cmd.exe"].filter(
Boolean,
) as string[]
}
const file = Bun.file("/etc/shells")
if (await file.exists()) {
const text = await file.text()
return text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))
}
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
export async function list(): Promise<Item[]> {
const list = process.platform === "win32" ? win() : await unix()
return list.map(info)
}
}

View file

@ -22,7 +22,6 @@ import { Config } from "../config/config"
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,
@ -294,8 +293,8 @@ async function shellEnv(ctx: Tool.Context, cwd: string) {
}
}
function launch(shell: string, name: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && PS.has(name)) {
function launch(shell: string, command: string, cwd: string, env: NodeJS.ProcessEnv) {
if (process.platform === "win32" && Shell.ps(shell)) {
return spawn(shell, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", command], {
cwd,
env,
@ -318,7 +317,6 @@ function launch(shell: string, name: string, command: string, cwd: string, env:
async function run(
input: {
shell: string
name: string
command: string
cwd: string
env: NodeJS.ProcessEnv
@ -327,7 +325,7 @@ async function run(
},
ctx: Tool.Context,
) {
const proc = launch(input.shell, input.name, input.command, input.cwd, input.env)
const proc = launch(input.shell, input.command, input.cwd, input.env)
let output = ""
ctx.metadata({
@ -479,7 +477,7 @@ export const BashTool = Tool.define("bash", async () => {
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 = await parse(params.command, ps)
const scan = await collect(root, cwd, ps, shell)
if (!Instance.containsPath(cwd)) scan.dirs.add(cwd)
@ -488,7 +486,6 @@ export const BashTool = Tool.define("bash", async () => {
return run(
{
shell,
name,
command: params.command,
cwd,
env: await shellEnv(ctx, cwd),

View file

@ -39,6 +39,15 @@ 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)
})
})
if (process.platform === "win32") {
test("rejects blacklisted shells case-insensitively", async () => {
await withShell("NU.EXE", async () => {

View file

@ -2418,7 +2418,11 @@ export type PtyShellsResponses = {
/**
* List of shells
*/
200: Array<string>
200: Array<{
path: string
name: string
acceptable: boolean
}>
}
export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses]