diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index ab3d09689252..0ffa5a5a90b1 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -16,6 +16,7 @@ import { DialogModel, useConnected } from "@tui/component/dialog-model"
import { DialogMcp } from "@tui/component/dialog-mcp"
import { DialogStatus } from "@tui/component/dialog-status"
import { DialogThemeList } from "@tui/component/dialog-theme-list"
+import { DialogExperimental } from "@tui/component/dialog-experimental"
import { DialogHelp } from "./ui/dialog-help"
import { CommandProvider, useCommandDialog } from "@tui/component/dialog-command"
import { DialogAgent } from "@tui/component/dialog-agent"
@@ -519,6 +520,17 @@ function App() {
},
category: "System",
},
+ {
+ title: "Enable experimental flag",
+ value: "opencode.experimental",
+ slash: {
+ name: "experimental",
+ },
+ onSelect: () => {
+ dialog.replace(() => )
+ },
+ category: "System",
+ },
{
title: "Switch theme",
value: "theme.switch",
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-experimental.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-experimental.tsx
new file mode 100644
index 000000000000..91d2fb18fd09
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-experimental.tsx
@@ -0,0 +1,135 @@
+import path from "path"
+import { createMemo, createResource } from "solid-js"
+import { DialogSelect } from "../ui/dialog-select"
+import { useSync } from "../context/sync"
+import { useDialog } from "../ui/dialog"
+import { DialogPrompt } from "../ui/dialog-prompt"
+import { useToast } from "../ui/toast"
+import { Flag } from "@/flag/flag"
+
+function esc(value: string) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+}
+
+function parse(text: string) {
+ return text.split(/\r?\n/).reduce(
+ (result, line) => {
+ const trimmed = line.trim()
+ if (!trimmed || trimmed.startsWith("#")) return result
+ const clean = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed
+ const index = clean.indexOf("=")
+ if (index <= 0) return result
+ result[clean.slice(0, index).trim()] = clean.slice(index + 1).trim()
+ return result
+ },
+ {} as Record,
+ )
+}
+
+function edit(text: string, key: string, value: string) {
+ const line = `${key}=${value}`
+ const lines = text.split(/\r?\n/)
+ const index = lines.findIndex((item) => new RegExp(`^\\s*(?:export\\s+)?${esc(key)}\\s*=`).test(item))
+ if (index !== -1) {
+ lines[index] = line
+ return lines.join("\n")
+ }
+ if (text.length === 0) return line + "\n"
+ const suffix = text.endsWith("\n") ? "" : "\n"
+ return text + suffix + line + "\n"
+}
+
+function label(key: string) {
+ return key.replace(/^OPENCODE_/, "").toLowerCase().replaceAll("_", " ")
+}
+
+export function DialogExperimental() {
+ const sync = useSync()
+ const dialog = useDialog()
+ const toast = useToast()
+
+ const file = createMemo(() => path.join(sync.data.path.directory || process.cwd(), ".env"))
+ const [text, { refetch }] = createResource(
+ file,
+ (target) => {
+ return Bun.file(target).text().catch(() => "")
+ },
+ { initialValue: "" },
+ )
+ const vars = createMemo(() => parse(text() ?? ""))
+ const flags = createMemo(() => {
+ const entries = Object.entries(Flag.types)
+ .filter(([key]) => key.includes("EXPERIMENTAL"))
+ .map(([key, type]) => ({ key, type }))
+ const set = new Set(entries.map((item) => item.key))
+ const extras = Object.keys(Flag)
+ .filter((key) => key.includes("EXPERIMENTAL") && !set.has(key))
+ .map((key) => ({ key, type: "boolean" as const }))
+ return [...entries, ...extras].toSorted((a, b) => a.key.localeCompare(b.key))
+ })
+
+ const options = createMemo(() =>
+ flags().map((flag) => {
+ const current = vars()[flag.key]
+ return {
+ title: flag.key,
+ value: flag.key,
+ description: label(flag.key),
+ category: flag.type === "number" ? "Numeric" : "Boolean",
+ footer: current ? `Current: ${current}` : undefined,
+ }
+ }),
+ )
+
+ return (
+ {
+ const flag = flags().find((item) => item.key === opt.value)
+ if (!flag) return
+
+ let value = "true"
+ if (flag.type === "number") {
+ let input = vars()[flag.key]
+ while (true) {
+ const result = await DialogPrompt.show(dialog, flag.key, {
+ value: input,
+ placeholder: "Positive integer",
+ })
+ if (result === null) return
+ value = result.trim()
+ if (/^[1-9]\d*$/.test(value)) break
+ toast.show({
+ variant: "error",
+ message: "Value must be a positive integer",
+ })
+ input = result
+ }
+ }
+
+ const output = edit(text() ?? "", flag.key, value)
+ const saved = await Bun.write(file(), output)
+ .then(() => true)
+ .catch(() => false)
+
+ if (!saved) {
+ toast.show({
+ variant: "error",
+ message: `Failed to write ${file()}`,
+ })
+ return
+ }
+
+ process.env[flag.key] = value
+ await refetch()
+ dialog.clear()
+ toast.show({
+ variant: "success",
+ message: `Enabled ${flag.key} in ${file()}. Restart OpenCode to apply.`,
+ duration: 6000,
+ })
+ }}
+ />
+ )
+}
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 0049d716d095..4b7358a566bb 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/packages/opencode/src/flag/flag.ts
@@ -1,9 +1,22 @@
+const meta: Record = {}
+
function truthy(key: string) {
+ meta[key] = "boolean"
const value = process.env[key]?.toLowerCase()
return value === "true" || value === "1"
}
+function number(key: string) {
+ meta[key] = "number"
+ const value = process.env[key]
+ if (!value) return undefined
+ const parsed = Number(value)
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
+}
+
export namespace Flag {
+ export const types = meta as Readonly>
+
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
@@ -54,13 +67,6 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
-
- function number(key: string) {
- const value = process.env[key]
- if (!value) return undefined
- const parsed = Number(value)
- return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
- }
}
// Dynamic getter for OPENCODE_DISABLE_PROJECT_CONFIG
diff --git a/packages/web/src/content/docs/tui.mdx b/packages/web/src/content/docs/tui.mdx
index 085eb6169f83..50785ebeed07 100644
--- a/packages/web/src/content/docs/tui.mdx
+++ b/packages/web/src/content/docs/tui.mdx
@@ -115,6 +115,20 @@ Open external editor for composing messages. Uses the editor set in your `EDITOR
---
+### experimental
+
+Quickly enable experimental OpenCode flags. This opens a picker and writes the selected flag to `.env` in your current directory.
+
+```bash frame="none"
+/experimental
+```
+
+:::note
+Restart OpenCode after changing an experimental flag.
+:::
+
+---
+
### exit
Exit OpenCode. _Aliases_: `/quit`, `/q`