Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -519,6 +520,17 @@ function App() {
},
category: "System",
},
{
title: "Enable experimental flag",
value: "opencode.experimental",
slash: {
name: "experimental",
},
onSelect: () => {
dialog.replace(() => <DialogExperimental />)
},
category: "System",
},
{
title: "Switch theme",
value: "theme.switch",
Expand Down
135 changes: 135 additions & 0 deletions packages/opencode/src/cli/cmd/tui/component/dialog-experimental.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>,
)
}

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 (
<DialogSelect
title="Experimental flags"
options={options()}
onSelect={async (opt) => {
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,
})
}}
/>
)
}
20 changes: 13 additions & 7 deletions packages/opencode/src/flag/flag.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
const meta: Record<string, "boolean" | "number"> = {}

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<Record<string, "boolean" | "number">>

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"]
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions packages/web/src/content/docs/tui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading