mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-15 01:19:29 +00:00
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:
parent
947a26f4df
commit
3a81b4b257
8 changed files with 166 additions and 101 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue