feat: Add terminal shell selection to settings

This commit is contained in:
LukeParkerDev 2026-04-02 12:13:13 +10:00
parent 48db7cf07a
commit 9cbfcfad12
15 changed files with 199 additions and 43 deletions

View file

@ -14,7 +14,9 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",

View file

@ -1,29 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
"types": ["node", "bun"]
},
"include": ["./index.ts", "./sst-env.d.ts"]
}

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]
}

View file

@ -74,7 +74,9 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"glob": "13.0.5",
"husky": "9.1.7",

View file

@ -10,6 +10,8 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import {
monoDefault,
monoFontFamily,
@ -133,6 +135,21 @@ export const SettingsGeneral: Component = () => {
const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
const globalSync = useGlobalSync()
const globalSdk = useGlobalSDK()
const [shells] = createResource(() => globalSdk.client.pty.shells().then((res) => res.data || []))
const shellOptions = createMemo(() => {
const list = shells() || []
const current = globalSync.data.config.shell
const options = list.map((s) => ({ value: s, label: s }))
if (current && !list.includes(current)) {
options.unshift({ value: current, label: current })
}
return options
})
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") },
{ value: "light", label: language.t("theme.scheme.light") },
@ -206,6 +223,32 @@ export const SettingsGeneral: Component = () => {
/>
</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 === globalSync.data.config.shell) ?? {
value: "auto",
label: "Auto (Default)",
}
}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => {
const value = option?.value === "auto" ? undefined : option?.value
globalSync.updateConfig({ shell: 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")}
@ -301,23 +344,24 @@ export const SettingsGeneral: Component = () => {
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
label={(o) => o.name}
data-action="settings-shell"
options={shellOptions()}
current={
shellOptions().find((o) => o.value === globalSync.data.config.shell) ?? {
value: "auto",
label: "Auto (Default)",
}
}
value={(o) => o.value}
label={(o) => o.label}
onSelect={(option) => {
if (!option) return
theme.setTheme(option.id)
}}
onHighlight={(option) => {
if (!option) return
theme.previewTheme(option.id)
return () => theme.cancelPreview()
const value = option?.value === "auto" ? undefined : option?.value
globalSync.updateConfig({ shell: value })
}}
variant="secondary"
size="small"
triggerVariant="settings"
triggerStyle={{ "min-width": "180px" }}
/>
</SettingsRow>

View file

@ -723,6 +723,9 @@ 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 default shell used for your terminal and the agent's background processes.",
"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

@ -803,6 +803,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
shell: z.string().optional().describe("Default shell to use for terminal and bash tool"),
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z

View file

@ -11,6 +11,7 @@ import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
import { PtyID } from "./schema"
import { Effect, Layer, ServiceMap } from "effect"
import { Config } from "../config/config"
export namespace Pty {
const log = Log.create({ service: "pty" })
@ -172,9 +173,10 @@ export namespace Pty {
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const s = yield* InstanceState.get(state)
const config = yield* Effect.promise(() => Config.get())
return yield* Effect.promise(async () => {
const id = PtyID.ascending()
const command = input.command || Shell.preferred()
const command = input.command || Shell.preferred(config.shell)
const args = input.args || []
if (Shell.login(command)) {
args.push("-l")

View file

@ -7,9 +7,31 @@ import { PtyID } from "@/pty/schema"
import { NotFoundError } from "../../storage/db"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { Shell } from "@/shell/shell"
export const PtyRoutes = lazy(() =>
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(z.string())),
},
},
},
},
}),
async (c) => {
return c.json(await Shell.available())
},
)
.get(
"/",
describeRoute({

View file

@ -104,7 +104,33 @@ export namespace Shell {
return POSIX.has(name(file))
}
export const preferred = lazy(() => select(process.env.SHELL))
const defaultPreferred = lazy(() => select(process.env.SHELL))
const defaultAcceptable = lazy(() => select(process.env.SHELL, { acceptable: true }))
export const acceptable = 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 available(): Promise<string[]> {
if (process.platform === "win32") {
return [gitbash(), Bun.which("pwsh"), Bun.which("powershell"), process.env.COMSPEC || "cmd.exe"].filter(
Boolean,
) as string[]
} else {
try {
const text = await import("fs/promises").then((fs) => fs.readFile("/etc/shells", "utf-8"))
return text.split("\n").filter((line) => line.trim() && !line.startsWith("#"))
} catch {
return ["/bin/bash", "/bin/zsh", "/bin/sh"]
}
}
}
}

View file

@ -18,6 +18,7 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncate"
import { Plugin } from "@/plugin"
import { Config } from "../config/config"
const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@ -441,7 +442,8 @@ const parser = lazy(async () => {
// TODO: we may wanna rename this tool so it works better on other shells
export const BashTool = Tool.define("bash", async () => {
const shell = Shell.acceptable()
const config = await Config.get()
const shell = Shell.acceptable(config.shell)
const name = Shell.name(shell)
const chain =
name === "powershell"

View file

@ -99,6 +99,7 @@ import type {
PtyListResponses,
PtyRemoveErrors,
PtyRemoveResponses,
PtyShellsResponses,
PtyUpdateErrors,
PtyUpdateResponses,
QuestionAnswer,
@ -663,6 +664,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

@ -1417,6 +1417,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
/**
@ -2400,6 +2404,25 @@ 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<string>
}
export type PtyShellsResponse = PtyShellsResponses[keyof PtyShellsResponses]
export type PtyListData = {
body?: never
path?: never

8
script/tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"types": ["node", "bun"]
},
"include": ["./**/*.ts"]
}

View file

@ -1,5 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {}
"compilerOptions": {
"types": ["node"]
},
"include": ["sst.config.ts", "sst-env.d.ts", "infra/**/*.ts"]
}