Compare commits

...

49 Commits

Author SHA1 Message Date
opencode-agent[bot]
b036f68335 Apply PR #14677: feat: add experimental hashline edit mode with dual-schema support 2026-02-23 08:23:31 +00:00
opencode-agent[bot]
5736da99df Apply PR #14471: [DO NOT MERGE]: beta badge for desktop app 2026-02-23 08:23:30 +00:00
opencode-agent[bot]
76eebd4a71 Apply PR #14448: refactor: migrate Bun.spawn to Process utility with timeout and cleanup 2026-02-23 08:23:30 +00:00
opencode-agent[bot]
62eaaef248 Apply PR #13968: split tui/server config 2026-02-23 08:23:30 +00:00
opencode-agent[bot]
55da8a900e Apply PR #12633: feat(tui): add auto-accept mode for permission requests 2026-02-23 08:23:29 +00:00
opencode-agent[bot]
8d6b2431ac Apply PR #12022: feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-23 08:23:29 +00:00
opencode-agent[bot]
56d022d375 Apply PR #11811: feat: make plan mode the default 2026-02-23 08:23:28 +00:00
Shawn
8e96447960 fix(app): correct inverted chevron direction in todo list (#14628)
Co-authored-by: shenghui kevin <shenghuikevin@shenghuideMac-mini.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 13:41:33 +05:30
Shoubhit Dash
3026a005b6 test: reorder hashline config test for beta merge 2026-02-23 10:05:12 +05:30
Shoubhit Dash
a6f802d7fe fix: align codex prompt with edit routing 2026-02-22 22:39:03 +05:30
Shoubhit Dash
9ef803be82 feat: enable hashline by default 2026-02-22 22:31:33 +05:30
Shoubhit Dash
ce5c827a6e chore: remove local opencode config flags 2026-02-22 19:46:59 +05:30
Shoubhit Dash
56decd79db feat: add experimental hashline edit mode 2026-02-22 19:40:34 +05:30
Sebastian Herrlinger
ecac998125 update docs 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
0037c4b45b dry 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
db0c8ea07b ensure jsonc error handling 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
d67db5bac1 project config can override keybind 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
131dea32b0 no bun file api 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
832daaedaf substitution in comments 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
ba34df54ac align test 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
fcc615fb1c follow up 2026-02-21 18:15:48 +01:00
Sebastian Herrlinger
02a66cdff2 follow up 2026-02-21 18:15:47 +01:00
Sebastian Herrlinger
2bf7fdc0f3 init 2026-02-21 18:15:47 +01:00
Dax
b9d96cef8c Merge branch 'dev' into feature/remove-bun-spawn 2026-02-21 00:20:01 -05:00
Dax Raad
d85102d699 revert: keep Bun.spawn in e2e-local.ts and stats.ts 2026-02-20 17:20:26 -05:00
Dax Raad
856b9e42f8 Merge remote-tracking branch 'origin/dev' into feature/remove-bun-spawn 2026-02-20 17:05:50 -05:00
Dax Raad
bd0c08c1f0 core: improve process reliability with proper cleanup and timeout handling
Replace Bun-specific stream utilities with standard Node.js APIs for better compatibility. Add automatic SIGKILL fallback when processes don't terminate within timeout period. Fix process stream reading to properly handle cancellation and avoid buffer deadlocks.
2026-02-20 16:49:37 -05:00
Adam
9d78b69cd3 wip(app): beta badge 2026-02-20 10:59:59 -06:00
Dax Raad
2c27715dc4 style: align process.ts with AGENTS.md style guide 2026-02-20 09:44:59 -05:00
Dax Raad
19178e4dba revert: keep Bun.spawn in e2e-local.ts 2026-02-20 09:42:41 -05:00
Dax Raad
deaf9c956f sync 2026-02-19 16:53:22 -05:00
Dax Raad
3e0dc15b59 fix: remove unnecessary braces from favicon glob pattern
The pattern **/{favicon}.{ico,png,svg,jpg,jpeg,webp} doesn't work with
the npm glob package. Changed to **/favicon.{ico,png,svg,jpg,jpeg,webp}
which correctly matches favicon files with any of the specified extensions.
2026-02-19 13:19:06 -05:00
Dax Raad
01b5e6487c test: rewrite glob tests based on Glob utility behavior
- Add tests for symlink following (default false, true when enabled)
- Add tests for dot option (include/exclude dotfiles)
- Add tests for scanSync
- Verify directories excluded by default (nodir: true)
- Verify directories included when include: 'all'
2026-02-19 13:13:05 -05:00
Dax Raad
9657d1bbfd fix: restore followSymlinks behavior in Glob utility
Add symlink: true to all locations that previously had followSymlinks: true:
- theme.tsx: custom themes
- config.ts: commands, agents, modes, plugins
- skill.ts: external, opencode, and custom skills
- registry.ts: custom tools

Also fix nodir to default to true (exclude directories) when include is not explicitly set to 'all'.
2026-02-19 13:11:38 -05:00
Dax Raad
bbfb7e95e0 refactor: migrate from Bun.Glob to npm glob package
Replace Bun.Glob usage with a new Glob utility wrapper around the npm 'glob' package.
This moves us off Bun-specific APIs toward standard Node.js compatible solutions.

Changes:
- Add new src/util/glob.ts utility module with scan(), scanSync(), and match()
- Default include option is 'file' (only returns files, not directories)
- Add symlink option (default: false) to control symlink following
- Migrate all 12 files using Bun.Glob to use the new Glob utility
- Add comprehensive tests for the glob utility

Breaking changes:
- Removed support for include: 'dir' option (use include: 'all' and filter manually)
- symlink now defaults to false (was true in most Bun.Glob usages)

Files migrated:
- src/util/log.ts
- src/util/filesystem.ts
- src/tool/truncation.ts
- src/session/instruction.ts
- src/storage/json-migration.ts
- src/storage/storage.ts
- src/project/project.ts
- src/cli/cmd/tui/context/theme.tsx
- src/config/config.ts
- src/tool/registry.ts
- src/skill/skill.ts
- src/file/ignore.ts
2026-02-19 12:38:24 -05:00
Dax
e31f00ad22 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-16 21:50:34 -05:00
LukeParkerDev
a90e8de050 add missing return 2026-02-11 13:24:17 +10:00
Aiden Cline
eabf770053 Merge branch 'dev' into utilize-family-in-dialog 2026-02-10 14:43:15 -06:00
Dax
86d7bdc542 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:55:01 -05:00
Dax
d3ab78bba0 Merge branch 'dev' into feat/auto-accept-permissions 2026-02-09 10:04:40 -05:00
Dax Raad
a531f3f36d core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands 2026-02-07 20:00:09 -05:00
Dax Raad
bb3382311d tui: standardize autoedit indicator text styling to match other status labels 2026-02-07 19:57:45 -05:00
Dax Raad
ad545d0cc9 tui: allow auto-accepting only edit permissions instead of all permissions 2026-02-07 19:52:53 -05:00
Dax Raad
ac244b1458 tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles 2026-02-07 17:03:34 -05:00
Dax Raad
f202536b65 tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' 2026-02-07 16:57:48 -05:00
Dax Raad
405cc3f610 tui: streamline permission toggle command naming and add keyboard shortcut support
Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity
and move the command to the Agent category for better discoverability.
Add permission_auto_accept_toggle keybind to enable keyboard shortcut
toggling of auto-accept mode for permission requests.
2026-02-07 16:51:55 -05:00
Dax Raad
878c1b8c2d feat(tui): add auto-accept mode for permission requests
Add a toggleable auto-accept mode that automatically accepts all incoming
permission requests with a 'once' reply. This is useful for users who want
to streamline their workflow when they trust the agent's actions.

Changes:
- Add permission_auto_accept keybind (default: shift+tab) to config
- Remove default for agent_cycle_reverse (was shift+tab)
- Add auto-accept logic in sync.tsx to auto-reply when enabled
- Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions")
- Add visual indicator showing 'auto-accept' when active
- Store auto-accept state in KV for persistence across sessions
2026-02-07 16:44:39 -05:00
Aiden Cline
bb4d978684 feat: update tui model dialog to utilize model family to reduce noise in list 2026-02-03 15:48:40 -06:00
Dax Raad
afec40e8da feat: make plan mode the default, remove experimental flag
- Remove OPENCODE_EXPERIMENTAL_PLAN_MODE flag from flag.ts
- Update prompt.ts to always use plan mode logic
- Update registry.ts to always include plan tools in CLI
- Remove flag documentation from cli.mdx
2026-02-02 10:40:40 -05:00
60 changed files with 3320 additions and 565 deletions

View File

@@ -265,6 +265,9 @@ export function Titlebar() {
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="bg-icon-interactive-base text-background-base font-medium px-2 rounded-sm uppercase font-mono">
BETA
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">

View File

@@ -87,7 +87,7 @@ export function SessionTodoDock(props: { todos: Todo[]; title: string; collapseL
icon="chevron-down"
size="normal"
variant="ghost"
classList={{ "rotate-180": !store.collapsed }}
classList={{ "rotate-180": store.collapsed }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()

View File

@@ -7,7 +7,7 @@
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"start": "vite start"
},
"dependencies": {

View File

@@ -2,46 +2,62 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
const file = process.argv[2]
console.log(file)
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
const result = z.toJSONSchema(Config.Info, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
// Preserve strictness: set additionalProperties: false for objects
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
schema.additionalProperties = false
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
// Preserve strictness: set additionalProperties: false for objects
if (
schema &&
typeof schema === "object" &&
schema.type === "object" &&
schema.additionalProperties === undefined
) {
schema.additionalProperties = false
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
return result
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
const configFile = process.argv[2]
const tuiFile = process.argv[3]
await Bun.write(file, JSON.stringify(result, null, 2))
console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
}

View File

@@ -63,6 +63,7 @@ export namespace Agent {
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
edit: "ask",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",

View File

@@ -4,20 +4,21 @@ import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { text } from "node:stream/consumers"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
export async function run(cmd: string[], options?: Process.Options) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
})
const result = Bun.spawn([which(), ...cmd], {
const result = Process.spawn([which(), ...cmd], {
...options,
stdout: "pipe",
stderr: "pipe",
@@ -28,23 +29,15 @@ export namespace BunProc {
},
})
const code = await result.exited
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
const stdout = result.stdout ? await text(result.stdout) : undefined
const stderr = result.stderr ? await text(result.stderr) : undefined
log.info("done", {
code,
stdout,
stderr,
})
if (code !== 0) {
throw new Error(`Command failed with exit code ${result.exitCode}`)
throw new Error(`Command failed with exit code ${code}`)
}
return result
}

View File

@@ -1,5 +1,7 @@
import { readableStreamToText, semver } from "bun"
import { semver } from "bun"
import { text } from "node:stream/consumers"
import { Log } from "../util/log"
import { Process } from "../util/process"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
@@ -9,7 +11,7 @@ export namespace PackageRegistry {
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], {
const result = Process.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
@@ -20,8 +22,8 @@ export namespace PackageRegistry {
})
const code = await result.exited
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
const stdout = result.stdout ? await text(result.stdout) : ""
const stderr = result.stderr ? await text(result.stderr) : ""
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr })

View File

@@ -11,6 +11,8 @@ import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
import { text } from "node:stream/consumers"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -263,8 +265,7 @@ export const AuthLoginCommand = cmd({
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
const exit = await proc.exited
@@ -273,7 +274,12 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await text(proc.stdout)
await Auth.set(args.url, {
type: "wellknown",
key: wellknown.auth.env,

View File

@@ -365,6 +365,11 @@ export const RunCommand = cmd({
action: "deny",
pattern: "*",
},
{
permission: "edit",
action: "allow",
pattern: "*",
},
]
function title() {

View File

@@ -6,6 +6,7 @@ import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
if (shouldPaginate) {
const proc = Bun.spawn({
cmd: pagerCmd(),
const proc = Process.spawn(pagerCmd(), {
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
})
if (!proc.stdin) {
console.log(output)
return
}
proc.stdin.write(output)
proc.stdin.end()
await proc.exited

View File

@@ -38,6 +38,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -104,6 +106,7 @@ import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -138,35 +141,37 @@ export function tui(input: {
<KVProvider>
<ToastProvider>
<RouteProvider>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>
@@ -457,6 +462,7 @@ function App() {
{
title: "Toggle MCPs",
value: "mcp.list",
search: "toggle mcps",
category: "Agent",
slash: {
name: "mcps",
@@ -532,8 +538,9 @@ function App() {
category: "System",
},
{
title: "Toggle appearance",
title: mode() === "dark" ? "Light mode" : "Dark mode",
value: "theme.switch_mode",
search: "toggle appearance",
onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark")
dialog.clear()
@@ -572,6 +579,7 @@ function App() {
},
{
title: "Toggle debug panel",
search: "toggle debug",
category: "System",
value: "app.debug",
onSelect: (dialog) => {
@@ -581,6 +589,7 @@ function App() {
},
{
title: "Toggle console",
search: "toggle console",
category: "System",
value: "app.console",
onSelect: (dialog) => {
@@ -621,6 +630,7 @@ function App() {
{
title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title",
value: "terminal.title.toggle",
search: "toggle terminal title",
keybind: "terminal_title_toggle",
category: "System",
onSelect: (dialog) => {
@@ -636,6 +646,7 @@ function App() {
{
title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations",
value: "app.toggle.animations",
search: "toggle animations",
category: "System",
onSelect: (dialog) => {
kv.set("animations_enabled", !kv.get("animations_enabled", true))
@@ -645,6 +656,7 @@ function App() {
{
title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping",
value: "app.toggle.diffwrap",
search: "toggle diff wrapping",
category: "System",
onSelect: (dialog) => {
const current = kv.get("diff_wrap_mode", "word")

View File

@@ -2,6 +2,9 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -63,8 +66,13 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({
url: args.url,
config,
args: {
continue: args.continue,
sessionID: args.session,

View File

@@ -7,6 +7,27 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -21,6 +42,7 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -72,8 +94,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) =>
pipe(
flatMap((provider) => {
const items = pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -104,8 +126,9 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
),
),
)
return items
}),
)
const popularProviders = !connected()
@@ -154,6 +177,13 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}

View File

@@ -77,6 +77,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
function promptModelWarning() {
toast.show({
@@ -170,6 +171,17 @@ export function Prompt(props: PromptProps) {
command.register(() => {
return [
{
title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit",
value: "permission.auto_accept.toggle",
search: "toggle permissions",
keybind: "permission_auto_accept_toggle",
category: "Agent",
onSelect: (dialog) => {
setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none"))
dialog.clear()
},
},
{
title: "Clear prompt",
value: "prompt.clear",
@@ -996,23 +1008,30 @@ export function Prompt(props: PromptProps) {
cursorColor={theme.text}
syntaxStyle={syntax()}
/>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<box flexDirection="row" flexShrink={0} paddingTop={1} gap={1} justifyContent="space-between">
<box flexDirection="row" gap={1}>
<text fg={highlight()}>
{store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "}
</text>
<Show when={store.mode === "normal"}>
<box flexDirection="row" gap={1}>
<text flexShrink={0} fg={keybind.leader ? theme.textMuted : theme.text}>
{local.model.parsed().model}
</text>
</Show>
</box>
<text fg={theme.textMuted}>{local.model.parsed().provider}</text>
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
</text>
</Show>
</box>
</Show>
</box>
<Show when={autoaccept() === "edit"}>
<text>
<span style={{ fg: theme.warning }}>autoedit</span>
</text>
</Show>
</box>
</box>

View File

@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

View File

@@ -1,5 +1,4 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
@@ -7,14 +6,15 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const sync = useSync()
const keybinds = createMemo(() => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
return pipe(
sync.data.config.keybinds ?? {},
(config.keybinds ?? {}) as Record<string, string>,
mapValues((value) => Keybind.parse(value)),
)
})

View File

@@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper"
import type { Snapshot } from "@/snapshot"
import { useExit } from "./exit"
import { useArgs } from "./args"
import { useKV } from "./kv"
import { batch, onMount } from "solid-js"
import { Log } from "@/util/log"
import type { Path } from "@opencode-ai/sdk"
@@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
})
const sdk = useSDK()
const kv = useKV()
const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit")
sdk.event.listen((e) => {
const event = e.details
@@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
case "permission.asked": {
const request = event.properties
if (autoaccept() === "edit" && request.permission === "edit") {
sdk.client.permission.reply({
reply: "once",
requestID: request.id,
})
break
}
const requests = store.permission[request.sessionID]
if (!requests) {
setStore("permission", request.sessionID, [request])
@@ -441,6 +451,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
get ready() {
return store.status !== "loading"
},
session: {
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)

View File

@@ -1,7 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
@@ -42,6 +41,7 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
type ThemeColors = {
primary: RGBA
@@ -280,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const sync = useSync()
const config = useTuiConfig()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = sync.data.config.theme
const theme = config.theme
if (theme) setStore("active", theme)
})

View File

@@ -0,0 +1,9 @@
import { TuiConfig } from "@/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
return props.config
},
})

View File

@@ -46,6 +46,7 @@ export function Home() {
{
title: tipsHidden() ? "Show tips" : "Hide tips",
value: "tips.toggle",
search: "toggle tips",
keybind: "tips_toggle",
category: "System",
onSelect: (dialog) => {

View File

@@ -78,6 +78,7 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers)
@@ -101,6 +102,7 @@ const context = createContext<{
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -113,6 +115,7 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -166,7 +169,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = sync.data.config.tui
const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -525,6 +528,7 @@ export function Session() {
{
title: sidebarVisible() ? "Hide sidebar" : "Show sidebar",
value: "session.sidebar.toggle",
search: "toggle sidebar",
keybind: "sidebar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -539,6 +543,7 @@ export function Session() {
{
title: conceal() ? "Disable code concealment" : "Enable code concealment",
value: "session.toggle.conceal",
search: "toggle code concealment",
keybind: "messages_toggle_conceal" as any,
category: "Session",
onSelect: (dialog) => {
@@ -549,6 +554,7 @@ export function Session() {
{
title: showTimestamps() ? "Hide timestamps" : "Show timestamps",
value: "session.toggle.timestamps",
search: "toggle timestamps",
category: "Session",
slash: {
name: "timestamps",
@@ -562,6 +568,7 @@ export function Session() {
{
title: showThinking() ? "Hide thinking" : "Show thinking",
value: "session.toggle.thinking",
search: "toggle thinking",
keybind: "display_thinking",
category: "Session",
slash: {
@@ -576,6 +583,7 @@ export function Session() {
{
title: showDetails() ? "Hide tool details" : "Show tool details",
value: "session.toggle.actions",
search: "toggle tool details",
keybind: "tool_details",
category: "Session",
onSelect: (dialog) => {
@@ -584,8 +592,9 @@ export function Session() {
},
},
{
title: "Toggle session scrollbar",
title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar",
value: "session.toggle.scrollbar",
search: "toggle session scrollbar",
keybind: "scrollbar_toggle",
category: "Session",
onSelect: (dialog) => {
@@ -988,6 +997,7 @@ export function Session() {
showGenericToolOutput,
diffWrapMode,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row">
@@ -1962,7 +1972,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme()
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
@@ -2019,7 +2029,9 @@ function Edit(props: ToolProps<typeof EditTool>) {
</Match>
<Match when={true}>
<InlineTool icon="←" pending="Preparing edit..." complete={props.input.filePath} part={props.part}>
Edit {normalizePath(props.input.filePath!)} {input({ replaceAll: props.input.replaceAll })}
Edit{" "}
{normalizePath(props.input.filePath!)}{" "}
{input({ replaceAll: "replaceAll" in props.input ? props.input.replaceAll : undefined })}
</InlineTool>
</Match>
</Switch>
@@ -2033,7 +2045,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.sync.data.config.tui?.diff_style
const diffStyle = ctx.tui.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})

View File

@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -48,14 +49,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const sync = useSync()
const config = useTuiConfig()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = sync.data.config.tui?.diff_style
const diffStyle = config.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})

View File

@@ -12,6 +12,8 @@ import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -135,6 +137,10 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
@@ -163,6 +169,8 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({
url,
config,
directory: cwd,
fetch: customFetch,
events,
args: {

View File

@@ -34,6 +34,7 @@ export interface DialogSelectOption<T = any> {
title: string
value: T
description?: string
search?: string
footer?: JSX.Element | string
category?: string
disabled?: boolean
@@ -85,8 +86,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
// users typically search by the item name, and not its category.
const result = fuzzysort
.go(needle, options, {
keys: ["title", "category"],
scoreFn: (r) => r[0].score * 2 + r[1].score,
keys: ["title", "category", "search"],
scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score,
})
.map((x) => x.obj)

View File

@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -87,7 +88,8 @@ export namespace Clipboard {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -96,11 +98,12 @@ export namespace Clipboard {
if (Bun.which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -109,11 +112,12 @@ export namespace Clipboard {
if (Bun.which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -125,7 +129,7 @@ export namespace Clipboard {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
@@ -140,6 +144,7 @@ export namespace Clipboard {
},
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})

View File

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -17,8 +18,7 @@ export namespace Editor {
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
const proc = Bun.spawn({
cmd: [...parts, filepath],
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",

View File

@@ -3,7 +3,6 @@ import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
@@ -33,6 +32,8 @@ import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -41,7 +42,7 @@ export namespace Config {
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function getManagedConfigDir(): string {
function systemManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
@@ -52,10 +53,14 @@ export namespace Config {
}
}
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
const managedDir = managedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
function merge(target: Info, source: Info): Info {
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -90,7 +95,7 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = merge(
result = mergeConfigConcatArrays(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
@@ -106,21 +111,18 @@ export namespace Config {
}
// Global user config overrides remote config.
result = merge(result, await global())
result = mergeConfigConcatArrays(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
result = merge(result, await loadFile(resolved))
}
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
result = mergeConfigConcatArrays(result, await loadFile(file))
}
}
@@ -128,31 +130,10 @@ export namespace Config {
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = [
Global.Path.config,
// Only scan project .opencode/ directories when project discovery is enabled
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
: []),
// Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
]
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
@@ -162,7 +143,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = merge(result, await loadFile(path.join(dir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -185,7 +166,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = merge(
result = mergeConfigConcatArrays(
result,
await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory,
@@ -199,9 +180,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedConfigDir)) {
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = merge(result, await loadFile(path.join(managedConfigDir, file)))
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
}
}
@@ -240,8 +221,6 @@ export namespace Config {
result.share = "auto"
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
@@ -306,7 +285,7 @@ export namespace Config {
}
}
async function needsInstall(dir: string) {
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -791,6 +770,7 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -831,7 +811,12 @@ export namespace Config {
command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
agent_list: z.string().optional().default("<leader>a").describe("List agents"),
agent_cycle: z.string().optional().default("tab").describe("Next agent"),
agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"),
permission_auto_accept_toggle: z
.string()
.optional()
.default("shift+tab")
.describe("Toggle auto-accept mode for permissions"),
variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
@@ -929,20 +914,6 @@ export namespace Config {
ref: "KeybindsConfig",
})
export const TUI = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -1017,10 +988,7 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
@@ -1186,6 +1154,16 @@ export namespace Config {
.object({
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
hashline_edit: z
.boolean()
.optional()
.describe("Enable hashline-backed edit/read tool behavior (default true, set false to disable)"),
hashline_autocorrect: z
.boolean()
.optional()
.describe(
"Enable hashline autocorrect cleanup for copied prefixes and formatting artifacts (default true)",
),
openTelemetry: z
.boolean()
.optional()
@@ -1240,86 +1218,37 @@ export namespace Config {
return result
})
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
const text = await readFile(filepath)
if (!text) return {}
return load(text, { path: filepath })
}
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = await ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
)
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const lines = text.split("\n")
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: source,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
})
).trim()
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: source,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
const parsed = Info.safeParse(normalized)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1343,13 +1272,7 @@ export namespace Config {
issues: parsed.error.issues,
})
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const { JsonError, InvalidError } = ConfigPaths
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
@@ -1360,15 +1283,6 @@ export namespace Config {
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export async function get() {
return state().then((x) => x.config)
}

View File

@@ -0,0 +1,155 @@
import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Filesystem.exists(target)
if (targetExists) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
const backup = file + ".tui-migration.bak"
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
const edits = modify(acc, [key], undefined, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
if (!edits.length) return acc
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {
const ok = await Filesystem.exists(file)
return ok ? file : undefined
}),
)
return existing.filter((file): file is string => !!file)
}

View File

@@ -0,0 +1,174 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
}

View File

@@ -0,0 +1,34 @@
import z from "zod"
import { Config } from "./config"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
)
.strict()
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -0,0 +1,118 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Global } from "@/global"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export type Info = z.output<typeof Info>
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
return {
config: result,
}
})
export async function get() {
return state().then((x) => x.config)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
return parsed.data
}
}

View File

@@ -7,6 +7,8 @@ import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { text } from "node:stream/consumers"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
@@ -153,17 +155,19 @@ export namespace Ripgrep {
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
const proc = Process.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
})
await proc.exited
if (proc.exitCode !== 0)
const exit = await proc.exited
if (exit !== 0) {
const stderr = proc.stderr ? await text(proc.stderr) : ""
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
stderr,
})
}
}
if (config.extension === "zip") {
const zipFileReader = new ZipReader(new BlobReader(new Blob([arrayBuffer])))
@@ -227,8 +231,7 @@ export namespace Ripgrep {
}
}
// Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
// See https://github.com/oven-sh/bun/issues/24012
// Guard against invalid cwd to provide a consistent ENOENT error.
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT",
@@ -237,41 +240,35 @@ export namespace Ripgrep {
})
}
const proc = Bun.spawn(args, {
const proc = Process.spawn(args, {
cwd: input.cwd,
stdout: "pipe",
stderr: "ignore",
maxBuffer: 1024 * 1024 * 20,
signal: input.signal,
abort: input.signal,
})
const reader = proc.stdout.getReader()
const decoder = new TextDecoder()
let buffer = ""
try {
while (true) {
input.signal?.throwIfAborted()
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (line) yield line
}
}
if (buffer) yield buffer
} finally {
reader.releaseLock()
await proc.exited
if (!proc.stdout) {
throw new Error("Process output not available")
}
let buffer = ""
const stream = proc.stdout as AsyncIterable<Buffer | string>
for await (const chunk of stream) {
input.signal?.throwIfAborted()
buffer += typeof chunk === "string" ? chunk : chunk.toString()
// Handle both Unix (\n) and Windows (\r\n) line endings
const lines = buffer.split(/\r?\n/)
buffer = lines.pop() || ""
for (const line of lines) {
if (line) yield line
}
}
if (buffer) yield buffer
await proc.exited
input.signal?.throwIfAborted()
}

View File

@@ -7,6 +7,7 @@ export namespace Flag {
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"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -50,7 +51,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
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"]
@@ -74,6 +75,17 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
configurable: false,
})
// Dynamic getter for OPENCODE_TUI_CONFIG
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
get() {
return process.env["OPENCODE_TUI_CONFIG"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime

View File

@@ -1,7 +1,8 @@
import { readableStreamToText } from "bun"
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -213,12 +214,13 @@ export const rlang: Info = {
if (airPath == null) return false
try {
const proc = Bun.spawn(["air", "--help"], {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
const output = await readableStreamToText(proc.stdout)
if (!proc.stdout) return false
const output = await text(proc.stdout)
// Check for "Air: An R language server and formatter"
const firstLine = output.split("\n")[0]
@@ -238,7 +240,7 @@ export const uvformat: Info = {
async enabled() {
if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) {
const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
}

View File

@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
export namespace Format {
const log = Log.create({ service: "format" })
@@ -110,13 +111,15 @@ export namespace Format {
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
})
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {

View File

@@ -4,12 +4,14 @@ import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { $, readableStreamToText } from "bun"
import { $ } from "bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -133,7 +135,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -263,14 +265,16 @@ export namespace LSPServer {
}
if (lintBin) {
const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" })
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
await proc.exited
const help = await readableStreamToText(proc.stdout)
if (help.includes("--lsp")) {
return {
process: spawn(lintBin, ["--lsp"], {
cwd: root,
}),
if (proc.stdout) {
const help = await text(proc.stdout)
if (help.includes("--lsp")) {
return {
process: spawn(lintBin, ["--lsp"], {
cwd: root,
}),
}
}
}
}
@@ -372,8 +376,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
@@ -414,8 +417,7 @@ export namespace LSPServer {
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing rubocop")
const proc = Bun.spawn({
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -513,7 +515,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], {
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -746,8 +748,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing csharp-ls via dotnet tool")
const proc = Bun.spawn({
cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -786,8 +787,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing fsautocomplete via dotnet tool")
const proc = Bun.spawn({
cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -1047,7 +1047,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1094,7 +1094,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1339,7 +1339,7 @@ export namespace LSPServer {
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1518,7 +1518,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1615,7 +1615,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1827,7 +1827,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,

View File

@@ -1322,33 +1322,7 @@ export namespace SessionPrompt {
const userMessage = input.messages.findLast((msg) => msg.info.role === "user")
if (!userMessage) return input.messages
// Original logic when experimental plan mode is disabled
if (!Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE) {
if (input.agent.name === "plan") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: PROMPT_PLAN,
synthetic: true,
})
}
const wasPlan = input.messages.some((msg) => msg.info.role === "assistant" && msg.info.agent === "plan")
if (wasPlan && input.agent.name === "build") {
userMessage.parts.push({
id: Identifier.ascending("part"),
messageID: userMessage.info.id,
sessionID: userMessage.info.sessionID,
type: "text",
text: BUILD_SWITCH,
synthetic: true,
})
}
return input.messages
}
// New plan mode logic when flag is enabled
// Plan mode logic
const assistantMessage = input.messages.findLast((msg) => msg.info.role === "assistant")
// Switching from plan mode to build mode

View File

@@ -5,7 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Only add comments if they are necessary to make a non-obvious block easier to understand.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- Prefer the edit tool for file edits. Use apply_patch only when it is available and clearly a better fit. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
## Tool usage
- Prefer specialized tools over shell for file operations:

View File

@@ -5,6 +5,7 @@
import z from "zod"
import * as path from "path"
import * as fs from "fs/promises"
import { Tool } from "./tool"
import { LSP } from "../lsp"
import { createTwoFilesPatch, diffLines } from "diff"
@@ -17,72 +18,158 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectory } from "./external-directory"
import {
HashlineEdit,
applyHashlineEdits,
hashlineOnlyCreates,
parseHashlineContent,
serializeHashlineContent,
} from "./hashline"
import { Config } from "../config/config"
const MAX_DIAGNOSTICS_PER_FILE = 20
const LEGACY_EDIT_MODE = "legacy"
const HASHLINE_EDIT_MODE = "hashline"
const LegacyEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
})
const HashlineEditParams = z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
edits: z.array(HashlineEdit).default([]),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
const EditParams = z
.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().optional().describe("The text to replace"),
newString: z.string().optional().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
edits: z.array(HashlineEdit).optional(),
delete: z.boolean().optional(),
rename: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
const legacy = value.oldString !== undefined || value.newString !== undefined || value.replaceAll !== undefined
const hashline = value.edits !== undefined || value.delete !== undefined || value.rename !== undefined
if (legacy && hashline) {
ctx.addIssue({
code: "custom",
message: "Do not mix legacy (oldString/newString) and hashline (edits/delete/rename) fields.",
})
return
}
if (!legacy && !hashline) {
ctx.addIssue({
code: "custom",
message: "Provide either legacy fields (oldString/newString) or hashline fields (edits/delete/rename).",
})
return
}
if (legacy) {
if (value.oldString === undefined || value.newString === undefined) {
ctx.addIssue({
code: "custom",
message: "Legacy payload requires both oldString and newString.",
})
}
return
}
if (value.edits === undefined) {
ctx.addIssue({
code: "custom",
message: "Hashline payload requires edits (use [] when only delete is intended).",
})
}
})
type LegacyEditParams = z.infer<typeof LegacyEditParams>
type HashlineEditParams = z.infer<typeof HashlineEditParams>
type EditParams = z.infer<typeof EditParams>
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: z.object({
filePath: z.string().describe("The absolute path to the file to modify"),
oldString: z.string().describe("The text to replace"),
newString: z.string().describe("The text to replace it with (must be different from oldString)"),
replaceAll: z.boolean().optional().describe("Replace all occurrences of oldString (default false)"),
}),
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
function isLegacyParams(params: EditParams): params is LegacyEditParams {
return params.oldString !== undefined || params.newString !== undefined || params.replaceAll !== undefined
}
async function withLocks(paths: string[], fn: () => Promise<void>) {
const unique = Array.from(new Set(paths)).sort((a, b) => a.localeCompare(b))
const recurse = async (idx: number): Promise<void> => {
if (idx >= unique.length) return fn()
await FileTime.withLock(unique[idx], () => recurse(idx + 1))
}
await recurse(0)
}
function createFileDiff(file: string, before: string, after: string): Snapshot.FileDiff {
const filediff: Snapshot.FileDiff = {
file,
before,
after,
additions: 0,
deletions: 0,
}
for (const change of diffLines(before, after)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
}
return filediff
}
async function diagnosticsOutput(filePath: string, output: string) {
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length === 0) {
return {
output,
diagnostics,
}
}
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
return {
output:
output +
`\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`,
diagnostics,
}
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
async function executeLegacy(params: LegacyEditParams, ctx: Tool.Context) {
if (params.oldString === params.newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
},
})
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: existed ? "change" : "add",
})
FileTime.read(ctx.sessionID, filePath)
return
}
const filePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
await assertExternalDirectory(ctx, filePath)
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
let diff = ""
let contentOld = ""
let contentNew = ""
await FileTime.withLock(filePath, async () => {
if (params.oldString === "") {
const existed = await Filesystem.exists(filePath)
contentNew = params.newString
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
@@ -92,64 +179,312 @@ export const EditTool = Tool.define("edit", {
diff,
},
})
await Filesystem.write(filePath, contentNew)
await Filesystem.write(filePath, params.newString)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
event: existed ? "change" : "add",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
})
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
additions: 0,
deletions: 0,
}
for (const change of diffLines(contentOld, contentNew)) {
if (change.added) filediff.additions += change.count || 0
if (change.removed) filediff.deletions += change.count || 0
return
}
ctx.metadata({
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
contentOld = await Filesystem.readText(filePath)
contentNew = replace(contentOld, params.oldString, params.newString, params.replaceAll)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filePath)],
always: ["*"],
metadata: {
filepath: filePath,
diff,
filediff,
diagnostics: {},
},
})
let output = "Edit applied successfully."
await LSP.touchFile(filePath, true)
const diagnostics = await LSP.diagnostics()
const normalizedFilePath = Filesystem.normalizePath(filePath)
const issues = diagnostics[normalizedFilePath] ?? []
const errors = issues.filter((item) => item.severity === 1)
if (errors.length > 0) {
const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
const suffix =
errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
output += `\n\nLSP errors detected in this file, please fix:\n<diagnostics file="${filePath}">\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</diagnostics>`
await Filesystem.write(filePath, contentNew)
await Bus.publish(File.Event.Edited, {
file: filePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
contentNew = await Filesystem.readText(filePath)
diff = trimDiff(
createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)),
)
FileTime.read(ctx.sessionID, filePath)
})
const filediff = createFileDiff(filePath, contentOld, contentNew)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics: {},
edit_mode: LEGACY_EDIT_MODE,
},
})
const result = await diagnosticsOutput(filePath, "Edit applied successfully.")
return {
metadata: {
diagnostics: result.diagnostics,
diff,
filediff,
edit_mode: LEGACY_EDIT_MODE,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output: result.output,
}
}
async function executeHashline(params: HashlineEditParams, ctx: Tool.Context, autocorrect: boolean) {
const sourcePath = path.isAbsolute(params.filePath) ? params.filePath : path.join(Instance.directory, params.filePath)
const targetPath = params.rename
? path.isAbsolute(params.rename)
? params.rename
: path.join(Instance.directory, params.rename)
: sourcePath
await assertExternalDirectory(ctx, sourcePath)
if (params.rename) {
await assertExternalDirectory(ctx, targetPath)
}
if (params.delete && params.edits.length > 0) {
throw new Error("delete=true cannot be combined with edits")
}
if (params.delete && params.rename) {
throw new Error("delete=true cannot be combined with rename")
}
let diff = ""
let before = ""
let after = ""
let noop = 0
let deleted = false
let changed = false
let diagnostics: Awaited<ReturnType<typeof LSP.diagnostics>> = {}
const paths = [sourcePath, targetPath]
await withLocks(paths, async () => {
const sourceStat = Filesystem.stat(sourcePath)
if (sourceStat?.isDirectory()) throw new Error(`Path is a directory, not a file: ${sourcePath}`)
const exists = Boolean(sourceStat)
if (params.rename && !exists) {
throw new Error("rename requires an existing source file")
}
if (params.delete) {
if (!exists) {
noop = 1
return
}
await FileTime.assert(ctx.sessionID, sourcePath)
before = await Filesystem.readText(sourcePath)
after = ""
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
await ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, sourcePath)],
always: ["*"],
metadata: {
filepath: sourcePath,
diff,
},
})
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: "unlink",
})
deleted = true
changed = true
return
}
if (!exists && !hashlineOnlyCreates(params.edits)) {
throw new Error("Missing file can only be created with append/prepend hashline edits")
}
if (exists) {
await FileTime.assert(ctx.sessionID, sourcePath)
}
const parsed = exists
? parseHashlineContent(await Filesystem.readBytes(sourcePath))
: {
bom: false,
eol: "\n",
trailing: false,
lines: [] as string[],
text: "",
raw: "",
}
before = parsed.raw
const next = applyHashlineEdits({
lines: parsed.lines,
trailing: parsed.trailing,
edits: params.edits,
autocorrect,
})
const output = serializeHashlineContent({
lines: next.lines,
trailing: next.trailing,
eol: parsed.eol,
bom: parsed.bom,
})
after = output.text
const noContentChange = before === after && sourcePath === targetPath
if (noContentChange) {
noop = 1
diff = trimDiff(
createTwoFilesPatch(sourcePath, sourcePath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
return
}
diff = trimDiff(
createTwoFilesPatch(sourcePath, targetPath, normalizeLineEndings(before), normalizeLineEndings(after)),
)
const patterns = [path.relative(Instance.worktree, sourcePath)]
if (sourcePath !== targetPath) patterns.push(path.relative(Instance.worktree, targetPath))
await ctx.ask({
permission: "edit",
patterns: Array.from(new Set(patterns)),
always: ["*"],
metadata: {
filepath: sourcePath,
diff,
},
})
if (sourcePath === targetPath) {
await Filesystem.write(sourcePath, output.bytes)
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: exists ? "change" : "add",
})
FileTime.read(ctx.sessionID, sourcePath)
changed = true
return
}
const targetExists = await Filesystem.exists(targetPath)
await Filesystem.write(targetPath, output.bytes)
await fs.rm(sourcePath, { force: true })
await Bus.publish(File.Event.Edited, {
file: sourcePath,
})
await Bus.publish(File.Event.Edited, {
file: targetPath,
})
await Bus.publish(FileWatcher.Event.Updated, {
file: sourcePath,
event: "unlink",
})
await Bus.publish(FileWatcher.Event.Updated, {
file: targetPath,
event: targetExists ? "change" : "add",
})
FileTime.read(ctx.sessionID, targetPath)
changed = true
})
const file = deleted ? sourcePath : targetPath
const filediff = createFileDiff(file, before, after)
ctx.metadata({
metadata: {
diff,
filediff,
diagnostics,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
})
if (!deleted && (changed || noop === 0)) {
const result = await diagnosticsOutput(targetPath, noop > 0 ? "No changes applied." : "Edit applied successfully.")
diagnostics = result.diagnostics
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, filePath)}`,
output,
title: `${path.relative(Instance.worktree, targetPath)}`,
output: result.output,
}
}
return {
metadata: {
diagnostics,
diff,
filediff,
edit_mode: HASHLINE_EDIT_MODE,
noop,
},
title: `${path.relative(Instance.worktree, file)}`,
output: deleted ? "Edit applied successfully." : "No changes applied.",
}
}
export const EditTool = Tool.define("edit", {
description: DESCRIPTION,
parameters: EditParams,
async execute(params, ctx) {
if (!params.filePath) {
throw new Error("filePath is required")
}
if (isLegacyParams(params)) {
return executeLegacy(params, ctx)
}
const config = await Config.get()
if (config.experimental?.hashline_edit === false) {
throw new Error(
"Hashline edit payload is disabled. Set experimental.hashline_edit to true to use hashline operations.",
)
}
const hashlineParams: HashlineEditParams = {
filePath: params.filePath,
edits: params.edits ?? [],
delete: params.delete,
rename: params.rename,
}
return executeHashline(
hashlineParams,
ctx,
config.experimental?.hashline_autocorrect !== false || Bun.env.OPENCODE_HL_AUTOCORRECT === "1",
)
},
})

View File

@@ -1,10 +1,31 @@
Performs exact string replacements in files.
Performs file edits with two supported payload schemas.
Usage:
- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.
- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: line number + colon + space (e.g., `1: `). Everything after that space is the actual file content to match. Never include any part of the line number prefix in the oldString or newString.
- You must use your `Read` tool at least once before editing an existing file. This tool rejects stale edits when file contents changed since read.
- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required.
- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked.
- The edit will FAIL if `oldString` is not found in the file with an error "oldString not found in content".
- The edit will FAIL if `oldString` is found multiple times in the file with an error "Found multiple matches for oldString. Provide more surrounding lines in oldString to identify the correct match." Either provide a larger string with more surrounding context to make it unique or use `replaceAll` to change every instance of `oldString`.
- Use `replaceAll` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance.
Legacy schema (always supported):
- `{ filePath, oldString, newString, replaceAll? }`
- Exact replacement only.
- The edit fails if `oldString` is not found.
- The edit fails if `oldString` matches multiple locations and `replaceAll` is not true.
- Use `replaceAll: true` for global replacements.
Hashline schema (default behavior):
- `{ filePath, edits, delete?, rename? }`
- Do not mix legacy fields (`oldString/newString/replaceAll`) with hashline fields (`edits/delete/rename`) in one call.
- Use strict anchor references from `Read` output: `LINE#ID`.
- Hashline mode can be turned off with `experimental.hashline_edit: false`.
- Autocorrect cleanup is on by default and can be turned off with `experimental.hashline_autocorrect: false`.
- When `Read` returns `LINE#ID:<content>`, prefer hashline operations.
- Operations:
- `set_line { line, text }`
- `replace_lines { start_line, end_line, text }`
- `insert_after { line, text }`
- `insert_before { line, text }`
- `insert_between { after_line, before_line, text }`
- `append { text }`
- `prepend { text }`
- `replace { old_text, new_text, all? }`
- In hashline mode, provide the exact `LINE#ID` anchors from the latest `Read` result. Mismatched anchors are rejected and must be retried with updated references.

View File

@@ -1,7 +1,9 @@
import z from "zod"
import { text } from "node:stream/consumers"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
@@ -44,14 +46,18 @@ export const GrepTool = Tool.define("grep", {
}
args.push(searchPath)
const proc = Bun.spawn([rgPath, ...args], {
const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe",
stderr: "pipe",
signal: ctx.abort,
abort: ctx.abort,
})
const output = await new Response(proc.stdout).text()
const errorOutput = await new Response(proc.stderr).text()
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const output = await text(proc.stdout)
const errorOutput = await text(proc.stderr)
const exitCode = await proc.exited
// Exit codes: 0 = matches found, 1 = no matches, 2 = errors (but may still have matches)

View File

@@ -0,0 +1,621 @@
// hashline autocorrect heuristics in this file are inspired by
// https://github.com/can1357/oh-my-pi (mit license), adapted for opencode.
import z from "zod"
export const HASHLINE_ALPHABET = "ZPMQVRWSNKTXJBYH"
const HASHLINE_ID_LENGTH = 2
const HASHLINE_ID_REGEX = new RegExp(`^[${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}}$`)
const HASHLINE_REF_REGEX = new RegExp(`(\\d+)#([${HASHLINE_ALPHABET}]{${HASHLINE_ID_LENGTH}})(?=$|\\s|:)`)
type TextValue = string | string[]
export const HashlineText = z.union([z.string(), z.array(z.string())])
export const HashlineEdit = z.discriminatedUnion("type", [
z
.object({
type: z.literal("set_line"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace_lines"),
start_line: z.string(),
end_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_after"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_before"),
line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("insert_between"),
after_line: z.string(),
before_line: z.string(),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("append"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("prepend"),
text: HashlineText,
})
.strict(),
z
.object({
type: z.literal("replace"),
old_text: z.string(),
new_text: HashlineText,
all: z.boolean().optional(),
})
.strict(),
])
export type HashlineEdit = z.infer<typeof HashlineEdit>
export function hashlineID(lineNumber: number, line: string): string {
let normalized = line
if (normalized.endsWith("\r")) normalized = normalized.slice(0, -1)
normalized = normalized.replace(/\s+/g, "")
void lineNumber
const hash = Bun.hash.xxHash32(normalized) & 0xff
const high = (hash >>> 4) & 0x0f
const low = hash & 0x0f
return `${HASHLINE_ALPHABET[high]}${HASHLINE_ALPHABET[low]}`
}
export function hashlineRef(lineNumber: number, line: string): string {
return `${lineNumber}#${hashlineID(lineNumber, line)}`
}
export function hashlineLine(lineNumber: number, line: string): string {
return `${hashlineRef(lineNumber, line)}:${line}`
}
export function parseHashlineRef(input: string, label: string) {
const match = input.match(HASHLINE_REF_REGEX)
if (!match) {
throw new Error(`${label} must contain a LINE#ID reference`)
}
const line = Number.parseInt(match[1], 10)
if (!Number.isInteger(line) || line < 1) {
throw new Error(`${label} has invalid line number: ${match[1]}`)
}
const id = match[2]
if (!HASHLINE_ID_REGEX.test(id)) {
throw new Error(`${label} has invalid hash id: ${id}`)
}
return {
raw: `${line}#${id}`,
line,
id,
}
}
function toLines(text: TextValue) {
if (Array.isArray(text)) return text
return text.split(/\r?\n/)
}
const HASHLINE_PREFIX_RE = /^\s*(?:>>>|>>)?\s*\d+#[ZPMQVRWSNKTXJBYH]{2}:/
const DIFF_PLUS_RE = /^[+-](?![+-])/
function stripNewLinePrefixes(lines: string[]) {
let hashPrefixCount = 0
let diffPlusCount = 0
let nonEmpty = 0
for (const line of lines) {
if (line.length === 0) continue
nonEmpty++
if (HASHLINE_PREFIX_RE.test(line)) hashPrefixCount++
if (DIFF_PLUS_RE.test(line)) diffPlusCount++
}
if (nonEmpty === 0) return lines
const stripHash = hashPrefixCount > 0 && hashPrefixCount >= nonEmpty * 0.5
const stripPlus = !stripHash && diffPlusCount > 0 && diffPlusCount >= nonEmpty * 0.5
if (!stripHash && !stripPlus) return lines
return lines.map((line) => {
if (stripHash) return line.replace(HASHLINE_PREFIX_RE, "")
if (stripPlus) return line.replace(DIFF_PLUS_RE, "")
return line
})
}
function equalsIgnoringWhitespace(a: string, b: string) {
if (a === b) return true
return a.replace(/\s+/g, "") === b.replace(/\s+/g, "")
}
function leadingWhitespace(line: string) {
const match = line.match(/^\s*/)
if (!match) return ""
return match[0]
}
function restoreLeadingIndent(template: string, line: string) {
if (line.length === 0) return line
const templateIndent = leadingWhitespace(template)
if (templateIndent.length === 0) return line
const indent = leadingWhitespace(line)
if (indent.length > 0) return line
return templateIndent + line
}
function restoreIndentForPairedReplacement(oldLines: string[], newLines: string[]) {
if (oldLines.length !== newLines.length) return newLines
let changed = false
const out = new Array<string>(newLines.length)
for (let idx = 0; idx < newLines.length; idx++) {
const restored = restoreLeadingIndent(oldLines[idx], newLines[idx])
out[idx] = restored
if (restored !== newLines[idx]) changed = true
}
if (changed) return out
return newLines
}
function stripAllWhitespace(s: string) {
return s.replace(/\s+/g, "")
}
function restoreOldWrappedLines(oldLines: string[], newLines: string[]) {
if (oldLines.length === 0 || newLines.length < 2) return newLines
const canonToOld = new Map<string, { line: string; count: number }>()
for (const line of oldLines) {
const canon = stripAllWhitespace(line)
const bucket = canonToOld.get(canon)
if (bucket) bucket.count++
if (!bucket) canonToOld.set(canon, { line, count: 1 })
}
const candidates: Array<{ start: number; len: number; replacement: string; canon: string }> = []
for (let start = 0; start < newLines.length; start++) {
for (let len = 2; len <= 10 && start + len <= newLines.length; len++) {
const canonSpan = stripAllWhitespace(newLines.slice(start, start + len).join(""))
const old = canonToOld.get(canonSpan)
if (old && old.count === 1 && canonSpan.length >= 6) {
candidates.push({
start,
len,
replacement: old.line,
canon: canonSpan,
})
}
}
}
if (candidates.length === 0) return newLines
const canonCounts = new Map<string, number>()
for (const candidate of candidates) {
canonCounts.set(candidate.canon, (canonCounts.get(candidate.canon) ?? 0) + 1)
}
const unique = candidates.filter((candidate) => (canonCounts.get(candidate.canon) ?? 0) === 1)
if (unique.length === 0) return newLines
unique.sort((a, b) => b.start - a.start)
const out = [...newLines]
for (const candidate of unique) {
out.splice(candidate.start, candidate.len, candidate.replacement)
}
return out
}
function stripInsertAnchorEchoAfter(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[0], anchorLine)) return lines.slice(1)
return lines
}
function stripInsertAnchorEchoBefore(anchorLine: string, lines: string[]) {
if (lines.length <= 1) return lines
if (equalsIgnoringWhitespace(lines[lines.length - 1], anchorLine)) return lines.slice(0, -1)
return lines
}
function stripInsertBoundaryEcho(afterLine: string, beforeLine: string, lines: string[]) {
let out = lines
if (out.length > 1 && equalsIgnoringWhitespace(out[0], afterLine)) out = out.slice(1)
if (out.length > 1 && equalsIgnoringWhitespace(out[out.length - 1], beforeLine)) out = out.slice(0, -1)
return out
}
function stripRangeBoundaryEcho(fileLines: string[], startLine: number, endLine: number, lines: string[]) {
const count = endLine - startLine + 1
if (lines.length <= 1 || lines.length <= count) return lines
let out = lines
const beforeIdx = startLine - 2
if (beforeIdx >= 0 && equalsIgnoringWhitespace(out[0], fileLines[beforeIdx])) {
out = out.slice(1)
}
const afterIdx = endLine
if (
afterIdx < fileLines.length &&
out.length > 0 &&
equalsIgnoringWhitespace(out[out.length - 1], fileLines[afterIdx])
) {
out = out.slice(0, -1)
}
return out
}
function ensureText(text: TextValue, label: string) {
const value = Array.isArray(text) ? text.join("") : text
if (value.length > 0) return
throw new Error(`${label} must be non-empty`)
}
function applyReplace(content: string, oldText: string, newText: TextValue, all = false) {
if (oldText.length === 0) throw new Error("replace.old_text must be non-empty")
const next = toLines(newText).join("\n")
const first = content.indexOf(oldText)
if (first < 0) throw new Error(`replace.old_text not found: ${JSON.stringify(oldText)}`)
if (all) return content.replaceAll(oldText, next)
const last = content.lastIndexOf(oldText)
if (first !== last) {
throw new Error("replace.old_text matched multiple times. Set all=true or provide a more specific old_text.")
}
return content.slice(0, first) + next + content.slice(first + oldText.length)
}
function mismatchContext(lines: string[], line: number) {
if (lines.length === 0) return ">>> (file is empty)"
const start = Math.max(1, line - 1)
const end = Math.min(lines.length, line + 1)
return Array.from({ length: end - start + 1 }, (_, idx) => start + idx)
.map((num) => {
const marker = num === line ? ">>>" : " "
return `${marker} ${hashlineLine(num, lines[num - 1])}`
})
.join("\n")
}
function throwMismatch(lines: string[], mismatches: Array<{ expected: string; line: number }>) {
const seen = new Set<string>()
const unique = mismatches.filter((m) => {
const key = `${m.expected}:${m.line}`
if (seen.has(key)) return false
seen.add(key)
return true
})
const body = unique
.map((m) => {
if (m.line < 1 || m.line > lines.length) {
return [
`>>> expected ${m.expected}`,
`>>> current line ${m.line} is out of range (1-${Math.max(lines.length, 1)})`,
].join("\n")
}
const current = hashlineRef(m.line, lines[m.line - 1])
return [`>>> expected ${m.expected}`, mismatchContext(lines, m.line), `>>> retry with ${current}`].join("\n")
})
.join("\n\n")
throw new Error(
[
"Hashline edit rejected: file changed since last read. Re-read the file and retry with updated LINE#ID anchors.",
body,
].join("\n\n"),
)
}
function validateAnchors(lines: string[], refs: Array<{ raw: string; line: number; id: string }>) {
const mismatches = refs
.filter((ref) => {
if (ref.line < 1 || ref.line > lines.length) return true
return hashlineID(ref.line, lines[ref.line - 1]) !== ref.id
})
.map((ref) => ({ expected: ref.raw, line: ref.line }))
if (mismatches.length > 0) throwMismatch(lines, mismatches)
}
function splitLines(text: string) {
if (text === "") {
return {
lines: [] as string[],
trailing: false,
}
}
const trailing = text.endsWith("\n")
const lines = text.split(/\r?\n/)
if (trailing) lines.pop()
return { lines, trailing }
}
export function parseHashlineContent(bytes: Buffer) {
const raw = bytes.toString("utf8")
let text = raw
const bom = raw.startsWith("\uFEFF")
if (bom) text = raw.slice(1)
const eol = text.includes("\r\n") ? "\r\n" : "\n"
const { lines, trailing } = splitLines(text)
return {
bom,
eol,
trailing,
lines,
text,
raw,
}
}
export function serializeHashlineContent(input: { lines: string[]; bom: boolean; eol: string; trailing: boolean }) {
let text = input.lines.join(input.eol)
if (input.trailing && input.lines.length > 0) text += input.eol
if (input.bom) text = `\uFEFF${text}`
return {
text,
bytes: Buffer.from(text, "utf8"),
}
}
type Splice = {
start: number
del: number
text: string[]
order: number
kind: "set_line" | "replace_lines" | "insert_after" | "insert_before" | "insert_between" | "append" | "prepend"
sortLine: number
precedence: number
startLine?: number
endLine?: number
anchorLine?: number
beforeLine?: number
afterLine?: number
}
export function applyHashlineEdits(input: {
lines: string[]
trailing: boolean
edits: HashlineEdit[]
autocorrect?: boolean
}) {
const lines = [...input.lines]
const originalLines = [...input.lines]
let trailing = input.trailing
const refs: Array<{ raw: string; line: number; id: string }> = []
const replaceOps: Array<Extract<HashlineEdit, { type: "replace" }>> = []
const ops: Splice[] = []
const autocorrect = input.autocorrect ?? Bun.env.OPENCODE_HL_AUTOCORRECT === "1"
const parseText = (text: TextValue) => {
const next = toLines(text)
if (!autocorrect) return next
return stripNewLinePrefixes(next)
}
input.edits.forEach((edit, order) => {
if (edit.type === "replace") {
replaceOps.push(edit)
return
}
if (edit.type === "append") {
ensureText(edit.text, "append.text")
ops.push({
start: lines.length,
del: 0,
text: parseText(edit.text),
order,
kind: "append",
sortLine: lines.length + 1,
precedence: 1,
})
return
}
if (edit.type === "prepend") {
ensureText(edit.text, "prepend.text")
ops.push({
start: 0,
del: 0,
text: parseText(edit.text),
order,
kind: "prepend",
sortLine: 0,
precedence: 2,
})
return
}
if (edit.type === "set_line") {
const line = parseHashlineRef(edit.line, "set_line.line")
refs.push(line)
ops.push({
start: line.line - 1,
del: 1,
text: parseText(edit.text),
order,
kind: "set_line",
sortLine: line.line,
precedence: 0,
startLine: line.line,
endLine: line.line,
})
return
}
if (edit.type === "replace_lines") {
const start = parseHashlineRef(edit.start_line, "replace_lines.start_line")
const end = parseHashlineRef(edit.end_line, "replace_lines.end_line")
refs.push(start)
refs.push(end)
if (start.line > end.line) {
throw new Error("replace_lines.start_line must be less than or equal to replace_lines.end_line")
}
ops.push({
start: start.line - 1,
del: end.line - start.line + 1,
text: parseText(edit.text),
order,
kind: "replace_lines",
sortLine: end.line,
precedence: 0,
startLine: start.line,
endLine: end.line,
})
return
}
if (edit.type === "insert_after") {
const line = parseHashlineRef(edit.line, "insert_after.line")
ensureText(edit.text, "insert_after.text")
refs.push(line)
ops.push({
start: line.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_after",
sortLine: line.line,
precedence: 1,
anchorLine: line.line,
})
return
}
if (edit.type === "insert_before") {
const line = parseHashlineRef(edit.line, "insert_before.line")
ensureText(edit.text, "insert_before.text")
refs.push(line)
ops.push({
start: line.line - 1,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_before",
sortLine: line.line,
precedence: 2,
anchorLine: line.line,
})
return
}
const after = parseHashlineRef(edit.after_line, "insert_between.after_line")
const before = parseHashlineRef(edit.before_line, "insert_between.before_line")
ensureText(edit.text, "insert_between.text")
refs.push(after)
refs.push(before)
if (after.line >= before.line) {
throw new Error("insert_between.after_line must be less than insert_between.before_line")
}
ops.push({
start: after.line,
del: 0,
text: parseText(edit.text),
order,
kind: "insert_between",
sortLine: before.line,
precedence: 3,
afterLine: after.line,
beforeLine: before.line,
})
})
validateAnchors(lines, refs)
const sorted = [...ops].sort((a, b) => {
if (a.sortLine !== b.sortLine) return b.sortLine - a.sortLine
if (a.precedence !== b.precedence) return a.precedence - b.precedence
return a.order - b.order
})
sorted.forEach((op) => {
if (op.start < 0 || op.start > lines.length) {
throw new Error(`line index ${op.start + 1} is out of range`)
}
let text = op.text
if (autocorrect) {
if (op.kind === "set_line" || op.kind === "replace_lines") {
const start = op.startLine ?? op.start + 1
const end = op.endLine ?? start + op.del - 1
const old = originalLines.slice(start - 1, end)
text = stripRangeBoundaryEcho(originalLines, start, end, text)
text = restoreOldWrappedLines(old, text)
text = restoreIndentForPairedReplacement(old, text)
}
if ((op.kind === "insert_after" || op.kind === "append") && op.anchorLine) {
text = stripInsertAnchorEchoAfter(originalLines[op.anchorLine - 1], text)
}
if ((op.kind === "insert_before" || op.kind === "prepend") && op.anchorLine) {
text = stripInsertAnchorEchoBefore(originalLines[op.anchorLine - 1], text)
}
if (op.kind === "insert_between" && op.afterLine && op.beforeLine) {
text = stripInsertBoundaryEcho(originalLines[op.afterLine - 1], originalLines[op.beforeLine - 1], text)
}
}
lines.splice(op.start, op.del, ...text)
})
if (replaceOps.length > 0) {
const content = `${lines.join("\n")}${trailing && lines.length > 0 ? "\n" : ""}`
const replaced = replaceOps.reduce(
(acc, op) =>
applyReplace(acc, op.old_text, autocorrect ? stripNewLinePrefixes(toLines(op.new_text)) : op.new_text, op.all),
content,
)
const split = splitLines(replaced)
lines.splice(0, lines.length, ...split.lines)
trailing = split.trailing
}
return {
lines,
trailing,
}
}
export function hashlineOnlyCreates(edits: HashlineEdit[]) {
return edits.every((edit) => edit.type === "append" || edit.type === "prepend")
}

View File

@@ -11,6 +11,8 @@ import { Instance } from "../project/instance"
import { assertExternalDirectory } from "./external-directory"
import { InstructionPrompt } from "../session/instruction"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { hashlineRef } from "./hashline"
const DEFAULT_READ_LIMIT = 2000
const MAX_LINE_LENGTH = 2000
@@ -156,6 +158,7 @@ export const ReadTool = Tool.define("read", {
const offset = params.offset ?? 1
const start = offset - 1
const raw: string[] = []
const full: string[] = []
let bytes = 0
let lines = 0
let truncatedByBytes = false
@@ -179,6 +182,7 @@ export const ReadTool = Tool.define("read", {
}
raw.push(line)
full.push(text)
bytes += size
}
} finally {
@@ -190,8 +194,11 @@ export const ReadTool = Tool.define("read", {
throw new Error(`Offset ${offset} is out of range for this file (${lines} lines)`)
}
const useHashline = (await Config.get()).experimental?.hashline_edit !== false
const content = raw.map((line, index) => {
return `${index + offset}: ${line}`
const lineNumber = index + offset
if (useHashline) return `${hashlineRef(lineNumber, full[index])}:${line}`
return `${lineNumber}: ${line}`
})
const preview = raw.slice(0, 20).join("\n")

View File

@@ -7,7 +7,10 @@ Usage:
- To read later sections, call this tool again with a larger offset.
- Use the grep tool to find specific content in large files or files with long lines.
- If you are unsure of the correct file path, use the glob tool to look up filenames by glob pattern.
- Contents are returned with each line prefixed by its line number as `<line>: <content>`. For example, if a file has contents "foo\n", you will receive "1: foo\n". For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Contents are returned with a line prefix.
- Default format: `LINE#ID:<content>` (example: `1#AB:foo`). Use these anchors for hashline edits.
- Legacy format can be restored with `experimental.hashline_edit: false`: `<line>: <content>` (example: `1: foo`).
- For directories, entries are returned one per line (without line numbers) with a trailing `/` for subdirectories.
- Any line longer than 2000 characters is truncated.
- Call this tool in parallel when you know there are multiple files you want to read.
- Avoid tiny repeated slices (30 line chunks). If you need more context, read a larger window.

View File

@@ -117,7 +117,7 @@ export namespace ToolRegistry {
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...(Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []),
...custom,
]
}
@@ -133,6 +133,7 @@ export namespace ToolRegistry {
},
agent?: Agent.Info,
) {
const config = await Config.get()
const tools = await all()
const result = await Promise.all(
tools
@@ -142,6 +143,11 @@ export namespace ToolRegistry {
return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA
}
if (config.experimental?.hashline_edit !== false) {
if (t.id === "apply_patch") return false
return true
}
// use apply tool in same format as codex
const usePatch =
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")

View File

@@ -1,5 +1,7 @@
import { $ } from "bun"
import { buffer } from "node:stream/consumers"
import { Flag } from "../flag/flag"
import { Process } from "./process"
export interface GitResult {
exitCode: number
@@ -14,12 +16,12 @@ export interface GitResult {
* Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
* case we fall back to `Process.spawn` with `stdin: "ignore"`.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
try {
const proc = Bun.spawn(["git", ...args], {
const proc = Process.spawn(["git", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -27,18 +29,15 @@ export async function git(args: string[], opts: { cwd: string; env?: Record<stri
env: opts.env ? { ...process.env, ...opts.env } : process.env,
})
// Read output concurrently with exit to avoid pipe buffer deadlock
const [exitCode, stdout, stderr] = await Promise.all([
proc.exited,
new Response(proc.stdout).arrayBuffer(),
new Response(proc.stderr).arrayBuffer(),
])
const stdoutBuf = Buffer.from(stdout)
const stderrBuf = Buffer.from(stderr)
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const [exitCode, out, err] = await Promise.all([proc.exited, buffer(proc.stdout), buffer(proc.stderr)])
return {
exitCode,
text: () => stdoutBuf.toString(),
stdout: stdoutBuf,
stderr: stderrBuf,
text: () => out.toString(),
stdout: out,
stderr: err,
}
} catch (error) {
const stderr = Buffer.from(error instanceof Error ? error.message : String(error))

View File

@@ -0,0 +1,71 @@
import { spawn as launch, type ChildProcess } from "child_process"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv | null
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
abort?: AbortSignal
kill?: NodeJS.Signals | number
timeout?: number
}
export type Child = ChildProcess & { exited: Promise<number> }
export function spawn(cmd: string[], options: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
options.abort?.throwIfAborted()
const proc = launch(cmd[0], cmd.slice(1), {
cwd: options.cwd,
env: options.env === null ? {} : options.env ? { ...process.env, ...options.env } : undefined,
stdio: [options.stdin ?? "ignore", options.stdout ?? "ignore", options.stderr ?? "ignore"],
})
let aborted = false
let timer: ReturnType<typeof setTimeout> | undefined
const abort = () => {
if (aborted) return
if (proc.exitCode !== null || proc.signalCode !== null) return
aborted = true
proc.kill(options.kill ?? "SIGTERM")
const timeout = options.timeout ?? 5_000
if (timeout <= 0) return
timer = setTimeout(() => {
proc.kill("SIGKILL")
}, timeout)
}
const exited = new Promise<number>((resolve, reject) => {
const done = () => {
options.abort?.removeEventListener("abort", abort)
if (timer) clearTimeout(timer)
}
proc.once("exit", (exitCode, signal) => {
done()
resolve(exitCode ?? (signal ? 1 : 0))
})
proc.once("error", (error) => {
done()
reject(error)
})
})
if (options.abort) {
options.abort.addEventListener("abort", abort, { once: true })
if (options.abort.aborted) abort()
}
const child = proc as Child
child.exited = exited
return child
}
}

View File

@@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => {
expect(build).toBeDefined()
expect(build?.mode).toBe("primary")
expect(build?.native).toBe(true)
expect(evalPerm(build, "edit")).toBe("allow")
expect(evalPerm(build, "edit")).toBe("ask")
expect(evalPerm(build, "bash")).toBe("allow")
},
})
@@ -217,8 +217,8 @@ test("agent permission config merges with defaults", async () => {
expect(build).toBeDefined()
// Specific pattern is denied
expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny")
// Edit still allowed
expect(evalPerm(build, "edit")).toBe("allow")
// Edit still asks (default behavior)
expect(evalPerm(build, "edit")).toBe("ask")
},
})
})

View File

@@ -56,6 +56,28 @@ test("loads JSON config file", async () => {
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
theme: "legacy",
tui: { scroll_speed: 4 },
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
},
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -80,6 +102,28 @@ test("loads JSONC config file", async () => {
})
})
test("parses experimental.hashline_edit and experimental.hashline_autocorrect", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: {
hashline_edit: true,
hashline_autocorrect: true,
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.experimental?.hashline_edit).toBe(true)
expect(config.experimental?.hashline_autocorrect).toBe(true)
},
})
})
test("merges multiple config files with correct precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -110,14 +154,14 @@ test("merges multiple config files with correct precedence", async () => {
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test_theme"
process.env["TEST_VAR"] = "test-user"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_VAR}",
username: "{env:TEST_VAR}",
})
},
})
@@ -125,7 +169,7 @@ test("handles environment variable substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
expect(config.username).toBe("test-user")
},
})
} finally {
@@ -148,7 +192,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
theme: "{env:PRESERVE_VAR}",
username: "{env:PRESERVE_VAR}",
}),
)
},
@@ -157,7 +201,7 @@ test("preserves env variables when adding $schema to config", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_value")
expect(config.username).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
@@ -178,10 +222,10 @@ test("preserves env variables when adding $schema to config", async () => {
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.txt}",
username: "{file:included.txt}",
})
},
})
@@ -189,7 +233,7 @@ test("handles file inclusion substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
expect(config.username).toBe("test-user")
},
})
})
@@ -200,7 +244,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.md}",
username: "{file:included.md}",
})
},
})
@@ -208,7 +252,7 @@ test("handles file inclusion with replacement tokens", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
expect(config.username).toBe("const out = await Bun.$`echo hi`")
},
})
})
@@ -1043,7 +1087,6 @@ test("managed settings override project settings", async () => {
$schema: "https://opencode.ai/config.json",
autoupdate: true,
disabled_providers: [],
theme: "dark",
})
},
})
@@ -1060,7 +1103,6 @@ test("managed settings override project settings", async () => {
const config = await Config.get()
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
expect(config.theme).toBe("dark")
},
})
})
@@ -1809,7 +1851,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_CONFIG_VAR}",
username: "{env:TEST_CONFIG_VAR}",
})
try {
@@ -1818,7 +1860,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_api_key_12345")
expect(config.username).toBe("test_api_key_12345")
},
})
} finally {
@@ -1841,10 +1883,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
theme: "{file:./api_key.txt}",
username: "{file:./api_key.txt}",
})
},
})
@@ -1852,7 +1894,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_key_from_file")
expect(config.username).toBe("secret_key_from_file")
},
})
} finally {

View File

@@ -0,0 +1,510 @@
import { afterEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
})
test("loads tui config with the same precedence order as server config paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(
path.join(dir, ".opencode", "tui.json"),
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
},
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 5 },
keybinds: { app_exit: "ctrl+q" },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
},
})
})
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "project-migrated",
tui: { scroll_speed: 2 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
},
})
})
test("drops unknown legacy tui keys during migration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 2, foo: 1 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
},
})
})
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
"theme": "broken-theme",
"tui": { "scroll_speed": 2 }
"username": "still-broken"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
expect(config.scroll_speed).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(source).toContain('"theme": "broken-theme"')
expect(source).toContain('"tui": { "scroll_speed": 2 }')
},
})
})
test("skips migration when tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBe("legacy")
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
},
})
})
test("continues loading tui config when legacy source cannot be stripped", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
},
})
const source = path.join(tmp.path, "opencode.json")
await fs.chmod(source, 0o444)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("readonly-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(source))
expect(server.theme).toBe("readonly-theme")
},
})
} finally {
await fs.chmod(source, 0o644)
}
})
test("migration backup preserves JSONC comments", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
// top-level comment
"theme": "jsonc-theme",
"tui": {
// nested comment
"scroll_speed": 1.5
}
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await TuiConfig.get()
const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
expect(backup).toContain("// top-level comment")
expect(backup).toContain("// nested comment")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
},
})
})
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const nested = path.join(dir, "apps", "client")
await fs.mkdir(nested, { recursive: true })
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
},
})
await Instance.provide({
directory: path.join(tmp.path, "apps", "client"),
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("nested-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
},
})
})
test("flattens nested tui key inside tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "outer",
tui: { scroll_speed: 3, diff_style: "stacked" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
// top-level keys take precedence over nested tui keys
expect(config.theme).toBe("outer")
},
})
})
test("top-level keys in tui.json take precedence over nested tui key", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
diff_style: "auto",
tui: { diff_style: "stacked", scroll_speed: 2 },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
},
})
})
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
// project tui.json overrides the custom path, same as server config precedence
expect(config.theme).toBe("project")
// project also set diff_style, so that wins
expect(config.diff_style).toBe("auto")
},
})
})
test("merges keybind overrides across precedence layers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.app_exit).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k")
},
})
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
},
})
})
test("does not derive tui path from OPENCODE_CONFIG", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const customDir = path.join(dir, "custom")
await fs.mkdir(customDir, { recursive: true })
await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
},
})
})
test("applies env and file substitutions in tui.json", async () => {
const original = process.env.TUI_THEME_TEST
process.env.TUI_THEME_TEST = "env-theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "{env:TUI_THEME_TEST}",
keybinds: { app_exit: "{file:keybind.txt}" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q")
},
})
} finally {
if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original
}
})
test("applies file substitutions when first identical token is in a commented line", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
await Bun.write(
path.join(dir, "tui.jsonc"),
`{
// "theme": "{file:theme.txt}",
"theme": "{file:theme.txt}"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("resolved-theme")
},
})
})
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
},
})
})
test("loads .opencode/tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
},
})
})
test("gracefully falls back when tui.json has invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-fallback")
expect(config.keybinds).toBeDefined()
},
})
})

View File

@@ -5,6 +5,7 @@ import { EditTool } from "../../src/tool/edit"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import { FileTime } from "../../src/file/time"
import { hashlineLine, hashlineRef } from "../../src/tool/hashline"
const ctx = {
sessionID: "test-edit-session",
@@ -493,4 +494,286 @@ describe("tool.edit", () => {
})
})
})
describe("hashline payload", () => {
test("replaces a single line in hashline mode", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
const result = await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nB\nc")
expect(result.metadata.edit_mode).toBe("hashline")
},
})
})
test("applies hashline autocorrect prefixes through config", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
hashline_autocorrect: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: hashlineLine(2, "B"),
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nB\nc")
},
})
})
test("supports range replacement and insert modes", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a\nb\nc\nd", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
FileTime.read(ctx.sessionID, filepath)
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace_lines",
start_line: hashlineRef(2, "b"),
end_line: hashlineRef(3, "c"),
text: ["B", "C"],
},
{
type: "insert_before",
line: hashlineRef(2, "b"),
text: "x",
},
{
type: "insert_after",
line: hashlineRef(3, "c"),
text: "y",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("a\nx\nB\nC\ny\nd")
},
})
})
test("creates missing files from append/prepend operations", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
const filepath = path.join(tmp.path, "created.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await edit.execute(
{
filePath: filepath,
edits: [
{
type: "prepend",
text: "start",
},
{
type: "append",
text: "end",
},
],
},
ctx,
)
const content = await fs.readFile(filepath, "utf-8")
expect(content).toBe("start\nend")
},
})
})
test("rejects missing files for non-append/prepend edits", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
})
const filepath = path.join(tmp.path, "missing.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "replace",
old_text: "a",
new_text: "b",
},
],
},
ctx,
),
).rejects.toThrow("Missing file can only be created")
},
})
})
test("supports delete and rename flows", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: true,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "src.txt"), "a\nb", "utf-8")
await fs.writeFile(path.join(dir, "delete.txt"), "delete me", "utf-8")
},
})
const source = path.join(tmp.path, "src.txt")
const target = path.join(tmp.path, "renamed.txt")
const doomed = path.join(tmp.path, "delete.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
FileTime.read(ctx.sessionID, source)
await edit.execute(
{
filePath: source,
rename: target,
edits: [
{
type: "set_line",
line: hashlineRef(2, "b"),
text: "B",
},
],
},
ctx,
)
expect(await fs.readFile(target, "utf-8")).toBe("a\nB")
await expect(fs.stat(source)).rejects.toThrow()
FileTime.read(ctx.sessionID, doomed)
await edit.execute(
{
filePath: doomed,
delete: true,
edits: [],
},
ctx,
)
await expect(fs.stat(doomed)).rejects.toThrow()
},
})
})
test("rejects hashline payload when experimental mode is disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await fs.writeFile(path.join(dir, "file.txt"), "a", "utf-8")
},
})
const filepath = path.join(tmp.path, "file.txt")
await Instance.provide({
directory: tmp.path,
fn: async () => {
const edit = await EditTool.init()
await expect(
edit.execute(
{
filePath: filepath,
edits: [
{
type: "append",
text: "b",
},
],
},
ctx,
),
).rejects.toThrow("Hashline edit payload is disabled")
},
})
})
})
})

View File

@@ -0,0 +1,184 @@
import { describe, expect, test } from "bun:test"
import { applyHashlineEdits, hashlineID, hashlineLine, hashlineRef, parseHashlineRef } from "../../src/tool/hashline"
function swapID(ref: string) {
const [line, id] = ref.split("#")
const next = id[0] === "Z" ? `P${id[1]}` : `Z${id[1]}`
return `${line}#${next}`
}
describe("tool.hashline", () => {
test("hash computation is stable and 2-char alphabet encoded", () => {
const a = hashlineID(1, " const x = 1")
const b = hashlineID(1, "constx=1")
const c = hashlineID(99, "constx=1")
expect(a).toBe(b)
expect(a).toBe(c)
expect(a).toMatch(/^[ZPMQVRWSNKTXJBYH]{2}$/)
})
test("autocorrect strips copied hashline prefixes when enabled", () => {
const old = Bun.env.OPENCODE_HL_AUTOCORRECT
Bun.env.OPENCODE_HL_AUTOCORRECT = "1"
try {
const result = applyHashlineEdits({
lines: ["a"],
trailing: false,
edits: [
{
type: "set_line",
line: hashlineRef(1, "a"),
text: hashlineLine(1, "a"),
},
],
})
expect(result.lines).toEqual(["a"])
} finally {
if (old === undefined) delete Bun.env.OPENCODE_HL_AUTOCORRECT
else Bun.env.OPENCODE_HL_AUTOCORRECT = old
}
})
test("parses strict LINE#ID references with tolerant extraction", () => {
const ref = parseHashlineRef(">>> 12#ZP:const value = 1", "line")
expect(ref.line).toBe(12)
expect(ref.id).toBe("ZP")
expect(ref.raw).toBe("12#ZP")
expect(() => parseHashlineRef("12#ab", "line")).toThrow("LINE#ID")
})
test("aggregates mismatch errors with >>> context and retry refs", () => {
const lines = ["alpha", "beta", "gamma"]
const wrong = swapID(hashlineRef(2, lines[1]))
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "set_line",
line: wrong,
text: "BETA",
},
],
}),
).toThrow("changed since last read")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "set_line",
line: wrong,
text: "BETA",
},
],
}),
).toThrow(">>> retry with")
})
test("applies batched line edits bottom-up for stable results", () => {
const lines = ["a", "b", "c", "d"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
const three = hashlineRef(3, lines[2])
const four = hashlineRef(4, lines[3])
const result = applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: three,
text: ["B", "C"],
},
{
type: "insert_after",
line: one,
text: "A1",
},
{
type: "set_line",
line: four,
text: "D",
},
],
})
expect(result.lines).toEqual(["a", "A1", "B", "C", "D"])
})
test("orders append and prepend deterministically on empty files", () => {
const result = applyHashlineEdits({
lines: [],
trailing: false,
edits: [
{
type: "append",
text: "end",
},
{
type: "prepend",
text: "start",
},
],
})
expect(result.lines).toEqual(["start", "end"])
})
test("validates ranges, between constraints, and non-empty insert text", () => {
const lines = ["a", "b", "c"]
const one = hashlineRef(1, lines[0])
const two = hashlineRef(2, lines[1])
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "replace_lines",
start_line: two,
end_line: one,
text: "x",
},
],
}),
).toThrow("start_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "insert_between",
after_line: two,
before_line: one,
text: "x",
},
],
}),
).toThrow("insert_between.after_line")
expect(() =>
applyHashlineEdits({
lines,
trailing: false,
edits: [
{
type: "append",
text: "",
},
],
}),
).toThrow("append.text")
})
})

View File

@@ -6,6 +6,7 @@ import { Filesystem } from "../../src/util/filesystem"
import { tmpdir } from "../fixture/fixture"
import { PermissionNext } from "../../src/permission/next"
import { Agent } from "../../src/agent/agent"
import { hashlineLine } from "../../src/tool/hashline"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -269,10 +270,10 @@ describe("tool.read truncation", () => {
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "offset.txt"), offset: 10, limit: 5 }, ctx)
expect(result.output).toContain("10: line10")
expect(result.output).toContain("14: line14")
expect(result.output).not.toContain("9: line10")
expect(result.output).not.toContain("15: line15")
expect(result.output).toContain(hashlineLine(10, "line10"))
expect(result.output).toContain(hashlineLine(14, "line14"))
expect(result.output).not.toContain(hashlineLine(9, "line9"))
expect(result.output).not.toContain(hashlineLine(15, "line15"))
expect(result.output).toContain("line10")
expect(result.output).toContain("line14")
expect(result.output).not.toContain("line0")
@@ -443,6 +444,50 @@ root_type Monster;`
})
})
describe("tool.read hashline output", () => {
test("returns LINE#ID prefixes by default", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "hashline.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "hashline.txt") }, ctx)
expect(result.output).toContain(hashlineLine(1, "foo"))
expect(result.output).toContain(hashlineLine(2, "bar"))
expect(result.output).not.toContain("1: foo")
},
})
})
test("keeps legacy line prefixes when hashline mode is disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
init: async (dir) => {
await Bun.write(path.join(dir, "legacy.txt"), "foo\nbar")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const read = await ReadTool.init()
const result = await read.execute({ filePath: path.join(tmp.path, "legacy.txt") }, ctx)
expect(result.output).toContain("1: foo")
expect(result.output).toContain("2: bar")
},
})
})
})
describe("tool.read loaded instructions", () => {
test("loads AGENTS.md from parent directory and includes in metadata", async () => {
await using tmp = await tmpdir({

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { ToolRegistry } from "../../src/tool/registry"
describe("tool.registry hashline routing", () => {
test.each([
{ providerID: "openai", modelID: "gpt-5" },
{ providerID: "anthropic", modelID: "claude-3-7-sonnet" },
])("disables apply_patch and enables edit by default (%o)", async (model) => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools(model)
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).toContain("write")
expect(ids).not.toContain("apply_patch")
},
})
})
test("keeps existing GPT apply_patch routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "openai",
modelID: "gpt-5",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("apply_patch")
expect(ids).not.toContain("edit")
},
})
})
test("keeps existing non-GPT routing when hashline is explicitly disabled", async () => {
await using tmp = await tmpdir({
config: {
experimental: {
hashline_edit: false,
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tools = await ToolRegistry.tools({
providerID: "anthropic",
modelID: "claude-3-7-sonnet",
})
const ids = tools.map((tool) => tool.id)
expect(ids).toContain("edit")
expect(ids).not.toContain("apply_patch")
},
})
})
})

View File

@@ -1067,6 +1067,10 @@ export type KeybindsConfig = {
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Toggle showing all models
*/
model_show_all_toggle?: string
/**
* Share current session
*/
@@ -1183,6 +1187,10 @@ export type KeybindsConfig = {
* Previous agent
*/
agent_cycle_reverse?: string
/**
* Toggle auto-accept mode for permissions
*/
permission_auto_accept_toggle?: string
/**
* Cycle model variants
*/

View File

@@ -558,6 +558,7 @@ OpenCode can be configured using environment variables.
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |
@@ -600,4 +601,3 @@ These environment variables enable experimental features that may change or be r
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Enable experimental Exa features |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Enable experimental LSP type checking |
| `OPENCODE_EXPERIMENTAL_MARKDOWN` | boolean | Enable experimental markdown features |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Enable plan mode |

View File

@@ -14,10 +14,11 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Theme configuration
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-5",
"autoupdate": true,
"server": {
"port": 4096,
},
}
```
@@ -34,7 +35,7 @@ Configuration files are **merged together**, not replaced.
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
---
@@ -95,7 +96,9 @@ You can enable specific servers in your local config:
### Global
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
For TUI-specific settings, use `~/.config/opencode/tui.json`.
Global config overrides remote organizational defaults.
@@ -105,6 +108,8 @@ Global config overrides remote organizational defaults.
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
For project-specific TUI settings, add `tui.json` alongside it.
:::tip
Place project specific config in the root of your project.
:::
@@ -146,7 +151,9 @@ The custom directory is loaded after the global config and `.opencode` directori
## Schema
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
Your editor should be able to validate and autocomplete based on the schema.
@@ -154,28 +161,24 @@ Your editor should be able to validate and autocomplete based on the schema.
### TUI
You can configure TUI-specific settings through the `tui` option.
Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
"$schema": "https://opencode.ai/tui.json",
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
```
Available options:
Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
[Learn more about using the TUI here](/docs/tui).
[Learn more about TUI configuration here](/docs/tui#configure).
---
@@ -301,12 +304,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
### Themes
You can configure the theme you want to use in your OpenCode config through the `theme` option.
Set your UI theme in `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"theme": ""
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
}
```
@@ -406,11 +409,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
### Keybinds
You can customize your keybinds through the `keybinds` option.
Customize keybinds in `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {}
}
```

View File

@@ -3,11 +3,11 @@ title: Keybinds
description: Customize your keybinds.
---
OpenCode has a list of keybinds that you can customize through the OpenCode config.
OpenCode has a list of keybinds that you can customize through `tui.json`.
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {
"leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
## Disable keybind
You can disable a keybind by adding the key to your config with a value of "none".
You can disable a keybind by adding the key to `tui.json` with a value of "none".
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"keybinds": {
"session_compact": "none"
}

View File

@@ -61,11 +61,11 @@ The system theme is for users who:
## Using a theme
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`.
```json title="opencode.json" {3}
```json title="tui.json" {3}
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
}
```

View File

@@ -355,24 +355,34 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
## Configure
You can customize TUI behavior through your OpenCode config file.
You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
```json title="opencode.json"
```json title="tui.json"
{
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
}
}
"$schema": "https://opencode.ai/tui.json",
"theme": "opencode",
"keybinds": {
"leader": "ctrl+x"
},
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
```
This is separate from `opencode.json`, which configures server/runtime behavior.
### Options
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
---