From da6183f3a17df2e8c46edcaec3f89758840de4a6 Mon Sep 17 00:00:00 2001 From: Arav Jain Date: Fri, 20 Feb 2026 09:47:42 -0600 Subject: [PATCH] Add experimental flag command --- packages/opencode/src/cli/cmd/tui/app.tsx | 12 ++ .../cmd/tui/component/dialog-experimental.tsx | 135 ++++++++++++++++++ packages/opencode/src/flag/flag.ts | 20 ++- packages/web/src/content/docs/tui.mdx | 14 ++ 4 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-experimental.tsx 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`