Merge branch 'dev' into project

This commit is contained in:
Dax Raad 2025-08-30 15:22:48 -04:00
commit 11afe56f63
178 changed files with 4236 additions and 6777 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "0.5.18",
"version": "0.5.29",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View file

@ -14,6 +14,10 @@ declare module "sst" {
"type": "sst.sst.Linkable"
"value": string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
}
"DATABASE_PASSWORD": {
"type": "sst.sst.Secret"
"value": string

View file

@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.5.18",
"version": "0.5.29",
"name": "opencode",
"type": "module",
"private": true,
@ -41,7 +41,7 @@
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "0.4.8",
"isomorphic-git": "1.32.1",
"ignore": "7.0.5",
"jsonc-parser": "3.3.1",
"minimatch": "10.0.3",
"open": "10.1.2",

View file

@ -97,11 +97,11 @@ if (!snapshot) {
const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim())
const pkgbuild = [
const binaryPkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='${pkg}'",
"pkgname='opencode-bin'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
@ -125,11 +125,58 @@ if (!snapshot) {
"",
].join("\n")
for (const pkg of ["opencode-bin"]) {
// Source-based PKGBUILD for opencode
const sourcePkgbuild = [
"# Maintainer: dax",
"# Maintainer: adam",
"",
"pkgname='opencode'",
`pkgver=${version.split("-")[0]}`,
"options=('!debug' '!strip')",
"pkgrel=1",
"pkgdesc='The AI coding agent built for the terminal.'",
"url='https://github.com/sst/opencode'",
"arch=('aarch64' 'x86_64')",
"license=('MIT')",
"provides=('opencode')",
"conflicts=('opencode-bin')",
"depends=('fzf' 'ripgrep')",
"makedepends=('git' 'bun-bin' 'go')",
"",
`source=("opencode-\${pkgver}.tar.gz::https://github.com/sst/opencode/archive/v${version}.tar.gz")`,
`sha256sums=('SKIP')`,
"",
"build() {",
` cd "opencode-\${pkgver}"`,
` bun install`,
" cd packages/tui",
` CGO_ENABLED=0 go build -ldflags="-s -w -X main.Version=\${pkgver}" -o tui cmd/opencode/main.go`,
" cd ../opencode",
` bun build --define OPENCODE_TUI_PATH="'$(realpath ../tui/tui)'" --define OPENCODE_VERSION="'\${pkgver}'" --compile --target=bun-linux-x64 --outfile=opencode ./src/index.ts`,
"}",
"",
"package() {",
` cd "opencode-\${pkgver}/packages/opencode"`,
' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"',
"}",
"",
].join("\n")
for (const [pkg, pkgbuild] of [
["opencode-bin", binaryPkgbuild],
["opencode", sourcePkgbuild],
]) {
await $`rm -rf ./dist/aur-${pkg}`
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
while (true) {
try {
await $`git clone ssh://aur@aur.archlinux.org/${pkg}.git ./dist/aur-${pkg}`
break
} catch (e) {
continue
}
}
await $`cd ./dist/aur-${pkg} && git checkout master`
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild.replace("${pkg}", pkg))
await Bun.file(`./dist/aur-${pkg}/PKGBUILD`).write(pkgbuild)
await $`cd ./dist/aur-${pkg} && makepkg --printsrcinfo > .SRCINFO`
await $`cd ./dist/aur-${pkg} && git add PKGBUILD .SRCINFO`
await $`cd ./dist/aur-${pkg} && git commit -m "Update to v${version}"`

View file

@ -17,6 +17,7 @@ export namespace App {
hostname: z.string(),
git: z.boolean(),
path: z.object({
home: z.string(),
config: z.string(),
data: z.string(),
root: z.string(),
@ -77,6 +78,7 @@ export namespace App {
},
git: git !== undefined,
path: {
home: os.homedir(),
config: Global.Path.config,
state: Global.Path.state,
data,

View file

@ -245,7 +245,7 @@ export const AuthLoginCommand = cmd({
}
if (provider === "vercel") {
prompts.log.info("You can create an api key in the dashboard")
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
const key = await prompts.password({

View file

@ -0,0 +1,20 @@
import { App } from "../../../app/app"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
const AppInfoCommand = cmd({
command: "info",
builder: (yargs) => yargs,
async handler() {
await bootstrap({ cwd: process.cwd() }, async () => {
const app = App.info()
console.log(JSON.stringify(app, null, 2))
})
},
})
export const AppCommand = cmd({
command: "app",
builder: (yargs) => yargs.command(AppInfoCommand).demandCommand(),
async handler() {},
})

View file

@ -29,8 +29,25 @@ const FileStatusCommand = cmd({
},
})
const FileListCommand = cmd({
command: "list <path>",
builder: (yargs) =>
yargs.positional("path", {
type: "string",
demandOption: true,
description: "File path to list",
}),
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
const files = await File.list(args.path)
console.log(JSON.stringify(files, null, 2))
})
},
})
export const FileCommand = cmd({
command: "file",
builder: (yargs) => yargs.command(FileReadCommand).command(FileStatusCommand).demandCommand(),
builder: (yargs) =>
yargs.command(FileReadCommand).command(FileStatusCommand).command(FileListCommand).demandCommand(),
async handler() {},
})

View file

@ -1,6 +1,7 @@
import { Global } from "../../../global"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
import { AppCommand } from "./app"
import { FileCommand } from "./file"
import { LSPCommand } from "./lsp"
import { RipgrepCommand } from "./ripgrep"
@ -11,6 +12,7 @@ export const DebugCommand = cmd({
command: "debug",
builder: (yargs) =>
yargs
.command(AppCommand)
.command(LSPCommand)
.command(RipgrepCommand)
.command(FileCommand)

View file

@ -16,7 +16,7 @@ const DiagnosticsCommand = cmd({
async handler(args) {
await bootstrap({ cwd: process.cwd() }, async () => {
await LSP.touchFile(args.file, true)
console.log(await LSP.diagnostics())
console.log(JSON.stringify(await LSP.diagnostics(), null, 2))
})
},
})

View file

@ -64,6 +64,11 @@ export const RunCommand = cmd({
if (!process.stdin.isTTY) message += "\n" + (await Bun.stdin.text())
if (message.trim().length === 0) {
UI.error("Message cannot be empty")
return
}
await bootstrap({ cwd: process.cwd() }, async () => {
const session = await (async () => {
if (args.continue) {
@ -171,12 +176,8 @@ export const RunCommand = cmd({
const result = await Session.chat({
sessionID: session.id,
messageID,
...(agent.model
? agent.model
: {
providerID,
modelID,
}),
providerID,
modelID,
agent: agent.name,
parts: [
{

View file

@ -342,7 +342,10 @@ export namespace Config {
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
tui: TUI.optional().describe("TUI specific settings"),
command: z.record(z.string(), Command).optional(),
command: z
.record(z.string(), Command)
.optional()
.describe("Command configuration, see https://opencode.ai/docs/commands"),
plugin: z.string().array().optional(),
snapshot: z.boolean().optional(),
share: z

View file

@ -3,8 +3,9 @@ import { Bus } from "../bus"
import { $ } from "bun"
import { createPatch } from "diff"
import path from "path"
import * as git from "isomorphic-git"
import { App } from "../app/app"
import fs from "fs"
import ignore from "ignore"
import { Log } from "../util/log"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
@ -25,6 +26,18 @@ export namespace File {
export type Info = z.infer<typeof Info>
export const Node = z
.object({
name: z.string(),
path: z.string(),
type: z.enum(["file", "directory"]),
ignored: z.boolean(),
})
.openapi({
ref: "FileNode",
})
export type Node = z.infer<typeof Node>
export const Event = {
Edited: Bus.event(
"file.edited",
@ -114,12 +127,8 @@ export namespace File {
.then((x) => x.trim())
if (project.vcs === "git") {
const rel = path.relative(Instance.worktree, full)
const diff = await git.status({
fs,
dir: Instance.worktree,
filepath: rel,
})
if (diff !== "unmodified") {
const diff = await $`git diff ${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
if (diff.trim()) {
const original = await $`git show HEAD:${rel}`.cwd(Instance.worktree).quiet().nothrow().text()
const patch = createPatch(file, original, content, "old", "new", {
context: Infinity,
@ -129,4 +138,38 @@ export namespace File {
}
return { type: "raw", content }
}
export async function list(dir?: string) {
const exclude = [".git", ".DS_Store"]
const app = App.info()
let ignored = (_: string) => false
if (app.git) {
const gitignore = Bun.file(path.join(app.path.root, ".gitignore"))
if (await gitignore.exists()) {
const ig = ignore().add(await gitignore.text())
ignored = ig.ignores.bind(ig)
}
}
const resolved = dir ? path.join(app.path.cwd, dir) : app.path.cwd
const nodes: Node[] = []
for (const entry of await fs.promises.readdir(resolved, { withFileTypes: true })) {
if (exclude.includes(entry.name)) continue
const fullPath = path.join(resolved, entry.name)
const relativePath = path.relative(app.path.cwd, fullPath)
const relativeToRoot = path.relative(app.path.root, fullPath)
const type = entry.isDirectory() ? "directory" : "file"
nodes.push({
name: entry.name,
path: relativePath,
type,
ignored: ignored(type === "directory" ? relativeToRoot + "/" : relativeToRoot),
})
}
return nodes.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
}
}

View file

@ -139,6 +139,7 @@ export namespace LSP {
s.broken.add(root + server.id)
handle.process.kill()
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
return undefined
})
if (!client) continue
s.clients.push(client)

View file

@ -148,6 +148,7 @@ export namespace LSPServer {
async spawn(root) {
const eslint = await Bun.resolve("eslint", Instance.directory).catch(() => {})
if (!eslint) return
log.info("spawning eslint server")
const serverPath = path.join(Global.Path.bin, "vscode-eslint", "server", "out", "eslintServer.js")
if (!(await Bun.file(serverPath).exists())) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@ -164,7 +165,9 @@ export namespace LSPServer {
const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
const finalPath = path.join(Global.Path.bin, "vscode-eslint")
if (await Bun.file(finalPath).exists()) {
const stats = await fs.stat(finalPath).catch(() => undefined)
if (stats) {
log.info("removing old eslint installation", { path: finalPath })
await fs.rm(finalPath, { force: true, recursive: true })
}
await fs.rename(extractedPath, finalPath)
@ -512,7 +515,35 @@ export namespace LSPServer {
export const RustAnalyzer: Info = {
id: "rust",
root: NearestRoot(["Cargo.toml", "Cargo.lock"]),
root: async (file, app) => {
const crateRoot = await NearestRoot(["Cargo.toml", "Cargo.lock"])(file, app)
if (crateRoot === undefined) {
return undefined
}
let currentDir = crateRoot
while (currentDir !== path.dirname(currentDir)) {
// Stop at filesystem root
const cargoTomlPath = path.join(currentDir, "Cargo.toml")
try {
const cargoTomlContent = await Bun.file(cargoTomlPath).text()
if (cargoTomlContent.includes("[workspace]")) {
return currentDir
}
} catch (err) {
// File doesn't exist or can't be read, continue searching up
}
const parentDir = path.dirname(currentDir)
if (parentDir === currentDir) break // Reached filesystem root
currentDir = parentDir
// Stop if we've gone above the app root
if (!currentDir.startsWith(app.path.root)) break
}
return crateRoot
},
extensions: [".rs"],
async spawn(root) {
const bin = Bun.which("rust-analyzer")

View file

@ -54,7 +54,7 @@ export namespace MCP {
let lastError: Error | undefined
for (const { name, transport } of transports) {
const client = await experimental_createMCPClient({
name: key,
name: "opencode",
transport,
}).catch((error) => {
lastError = error instanceof Error ? error : new Error(String(error))
@ -91,7 +91,7 @@ export namespace MCP {
if (mcp.type === "local") {
const [cmd, ...args] = mcp.command
const client = await experimental_createMCPClient({
name: key,
name: "opencode",
transport: new StdioClientTransport({
stderr: "ignore",
command: cmd,

View file

@ -936,6 +936,34 @@ export namespace Server {
)
.get(
"/file",
describeRoute({
description: "List files and directories",
operationId: "file.list",
responses: {
200: {
description: "Files and directories",
content: {
"application/json": {
schema: resolver(File.Node.array()),
},
},
},
},
}),
zValidator(
"query",
z.object({
path: z.string(),
}),
),
async (c) => {
const path = c.req.valid("query").path
const content = await File.list(path)
return c.json(content)
},
)
.get(
"/file/content",
describeRoute({
description: "Read a file",
operationId: "file.read",
@ -964,10 +992,6 @@ export namespace Server {
async (c) => {
const path = c.req.valid("query").path
const content = await File.read(path)
log.info("read file", {
path,
content: content.content,
})
return c.json(content)
},
)

View file

@ -1,4 +1,5 @@
import path from "path"
import os from "os"
import { spawn } from "child_process"
import { Decimal } from "decimal.js"
import { z, ZodSchema } from "zod"
@ -721,7 +722,9 @@ export namespace Session {
draft.title = title.trim()
})
})
.catch(() => {})
.catch((error) => {
log.error("failed to generate title", { error, model: small.info.id })
})
}
const agent = await Agent.get(inputAgent)
@ -866,11 +869,31 @@ export namespace Session {
const execute = item.execute
if (!execute) continue
item.execute = async (args, opts) => {
await Plugin.trigger(
"tool.execute.before",
{
tool: key,
sessionID: input.sessionID,
callID: opts.toolCallId,
},
{
args,
},
)
const result = await execute(args, opts)
const output = result.content
.filter((x: any) => x.type === "text")
.map((x: any) => x.text)
.join("\n\n")
await Plugin.trigger(
"tool.execute.after",
{
tool: key,
sessionID: input.sessionID,
callID: opts.toolCallId,
},
result,
)
return {
output,
@ -1041,6 +1064,25 @@ export namespace Session {
export type ShellInput = z.infer<typeof ShellInput>
export async function shell(input: ShellInput) {
using abort = lock(input.sessionID)
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
time: {
created: Date.now(),
},
role: "user",
}
await updateMessage(userMsg)
const userPart: MessageV2.Part = {
type: "text",
id: Identifier.ascending("part"),
messageID: userMsg.id,
sessionID: input.sessionID,
text: "The following tool was executed by the user",
synthetic: true,
}
await updatePart(userPart)
const msg: MessageV2.Assistant = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
@ -1177,14 +1219,22 @@ export namespace Session {
export async function command(input: CommandInput) {
const command = await Command.get(input.command)
const agent = input.agent ?? command.agent ?? "build"
const agent = command.agent ?? input.agent ?? "build"
const fmtModel = (model: { providerID: string; modelID: string }) => `${model.providerID}/${model.modelID}`
const model =
input.model ??
command.model ??
(await Agent.get(agent).then((x) => (x.model ? `${x.model.providerID}/${x.model.modelID}` : undefined))) ??
(await Provider.defaultModel().then((x) => `${x.providerID}/${x.modelID}`))
(command.agent && (await Agent.get(command.agent).then((x) => (x.model ? fmtModel(x.model) : undefined)))) ??
input.model ??
(input.agent && (await Agent.get(input.agent).then((x) => (x.model ? fmtModel(x.model) : undefined)))) ??
fmtModel(await Provider.defaultModel())
let template = command.template.replace("$ARGUMENTS", input.arguments)
// intentionally doing match regex doing bash regex replacements
// this is because bash commands can output "@" references
const fileMatches = template.matchAll(fileRegex)
const bash = Array.from(template.matchAll(bashRegex))
if (bash.length > 0) {
const results = await Promise.all(
@ -1207,15 +1257,18 @@ export namespace Session {
},
] as ChatInput["parts"]
const matches = template.matchAll(fileRegex)
const app = App.info()
for (const match of matches) {
const file = path.join(app.path.cwd, match[1])
for (const match of fileMatches) {
const filename = match[1]
const filepath = filename.startsWith("~/")
? path.join(os.homedir(), filename.slice(2))
: path.join(app.path.cwd, filename)
parts.push({
type: "file",
url: `file://${file}`,
filename: match[1],
url: `file://${filepath}`,
filename,
mime: "text/plain",
})
}

View file

@ -109,6 +109,9 @@ IMPORTANT: When the user asks you to create a pull request, follow these steps c
gh pr create --title "the pr title" --body "$(cat <<'EOF'
## Summary
<1-3 bullet points>
EOF
)"
</example>
Important:
- NEVER update the git config

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "0.5.18",
"version": "0.5.29",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
@ -19,7 +19,6 @@
"@opencode-ai/sdk": "workspace:*"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.81.0",
"@tsconfig/node22": "catalog:",
"typescript": "catalog:"
}

View file

@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "0.5.18",
"version": "0.5.29",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit"
@ -29,6 +29,6 @@
"@tsconfig/node22": "catalog:"
},
"dependencies": {
"@hey-api/openapi-ts": "0.80.1"
"@hey-api/openapi-ts": "0.81.0"
}
}

View file

@ -1,8 +1,8 @@
export * from "./gen/types.gen.js"
export { type Config as OpencodeClientConfig, OpencodeClient }
import { createClient } from "./gen/client/client.js"
import { type Config } from "./gen/client/types.js"
import { createClient } from "./gen/client/client.gen.js"
import { type Config } from "./gen/client/types.gen.js"
import { OpencodeClient } from "./gen/sdk.gen.js"
export function createOpencodeClient(config?: Config) {

View file

@ -1,4 +1,7 @@
import type { Client, Config, RequestOptions } from "./types.js"
// This file is auto-generated by @hey-api/openapi-ts
import { createSseClient } from "../core/serverSentEvents.gen.js"
import type { Client, Config, RequestOptions, ResolvedRequestOptions } from "./types.gen.js"
import {
buildUrl,
createConfig,
@ -7,7 +10,7 @@ import {
mergeConfigs,
mergeHeaders,
setAuthParams,
} from "./utils.js"
} from "./utils.gen.js"
type ReqInit = Omit<RequestInit, "body" | "headers"> & {
body?: any
@ -24,14 +27,15 @@ export const createClient = (config: Config = {}): Client => {
return getConfig()
}
const interceptors = createInterceptors<Request, Response, unknown, RequestOptions>()
const interceptors = createInterceptors<Request, Response, unknown, ResolvedRequestOptions>()
const request: Client["request"] = async (options) => {
const beforeRequest = async (options: RequestOptions) => {
const opts = {
..._config,
...options,
fetch: options.fetch ?? _config.fetch ?? globalThis.fetch,
headers: mergeHeaders(_config.headers, options.headers),
serializedBody: undefined,
}
if (opts.security) {
@ -46,18 +50,26 @@ export const createClient = (config: Config = {}): Client => {
}
if (opts.body && opts.bodySerializer) {
opts.body = opts.bodySerializer(opts.body)
opts.serializedBody = opts.bodySerializer(opts.body)
}
// remove Content-Type header if body is empty to avoid sending invalid requests
if (opts.body === undefined || opts.body === "") {
if (opts.serializedBody === undefined || opts.serializedBody === "") {
opts.headers.delete("Content-Type")
}
const url = buildUrl(opts)
return { opts, url }
}
const request: Client["request"] = async (options) => {
// @ts-expect-error
const { opts, url } = await beforeRequest(options)
const requestInit: ReqInit = {
redirect: "follow",
...opts,
body: opts.serializedBody,
}
let request = new Request(url, requestInit)
@ -166,20 +178,35 @@ export const createClient = (config: Config = {}): Client => {
}
}
const makeMethod = (method: Required<Config>["method"]) => {
const fn = (options: RequestOptions) => request({ ...options, method })
fn.sse = async (options: RequestOptions) => {
const { opts, url } = await beforeRequest(options)
return createSseClient({
...opts,
body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method,
url,
})
}
return fn
}
return {
buildUrl,
connect: (options) => request({ ...options, method: "CONNECT" }),
delete: (options) => request({ ...options, method: "DELETE" }),
get: (options) => request({ ...options, method: "GET" }),
connect: makeMethod("CONNECT"),
delete: makeMethod("DELETE"),
get: makeMethod("GET"),
getConfig,
head: (options) => request({ ...options, method: "HEAD" }),
head: makeMethod("HEAD"),
interceptors,
options: (options) => request({ ...options, method: "OPTIONS" }),
patch: (options) => request({ ...options, method: "PATCH" }),
post: (options) => request({ ...options, method: "POST" }),
put: (options) => request({ ...options, method: "PUT" }),
options: makeMethod("OPTIONS"),
patch: makeMethod("PATCH"),
post: makeMethod("POST"),
put: makeMethod("PUT"),
request,
setConfig,
trace: (options) => request({ ...options, method: "TRACE" }),
}
trace: makeMethod("TRACE"),
} as Client
}

View file

@ -1,8 +1,14 @@
export type { Auth } from "../core/auth.js"
export type { QuerySerializerOptions } from "../core/bodySerializer.js"
export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.js"
export { buildClientParams } from "../core/params.js"
export { createClient } from "./client.js"
// This file is auto-generated by @hey-api/openapi-ts
export type { Auth } from "../core/auth.gen.js"
export type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"
export {
formDataBodySerializer,
jsonBodySerializer,
urlSearchParamsBodySerializer,
} from "../core/bodySerializer.gen.js"
export { buildClientParams } from "../core/params.gen.js"
export { createClient } from "./client.gen.js"
export type {
Client,
ClientOptions,
@ -12,7 +18,8 @@ export type {
OptionsLegacyParser,
RequestOptions,
RequestResult,
ResolvedRequestOptions,
ResponseStyle,
TDataShape,
} from "./types.js"
export { createConfig, mergeHeaders } from "./utils.js"
} from "./types.gen.js"
export { createConfig, mergeHeaders } from "./utils.gen.js"

View file

@ -1,6 +1,9 @@
import type { Auth } from "../core/auth.js"
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.js"
import type { Middleware } from "./utils.js"
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth } from "../core/auth.gen.js"
import type { ServerSentEventsOptions, ServerSentEventsResult } from "../core/serverSentEvents.gen.js"
import type { Client as CoreClient, Config as CoreConfig } from "../core/types.gen.js"
import type { Middleware } from "./utils.gen.js"
export type ResponseStyle = "data" | "fields"
@ -49,13 +52,18 @@ export interface Config<T extends ClientOptions = ClientOptions>
}
export interface RequestOptions<
TData = unknown,
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends Config<{
responseStyle: TResponseStyle
throwOnError: ThrowOnError
}> {
responseStyle: TResponseStyle
throwOnError: ThrowOnError
}>,
Pick<
ServerSentEventsOptions<TData>,
"onSseError" | "onSseEvent" | "sseDefaultRetryDelay" | "sseMaxRetryAttempts" | "sseMaxRetryDelay"
> {
/**
* Any body that you want to add to your request.
*
@ -71,6 +79,14 @@ export interface RequestOptions<
url: Url
}
export interface ResolvedRequestOptions<
TResponseStyle extends ResponseStyle = "fields",
ThrowOnError extends boolean = boolean,
Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
serializedBody?: string
}
export type RequestResult<
TData = unknown,
TError = unknown,
@ -112,23 +128,36 @@ export interface ClientOptions {
throwOnError?: boolean
}
type MethodFn = <
type MethodFnBase = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method">,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type MethodFnServerSentEvents = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method">,
) => Promise<ServerSentEventsResult<TData, TError>>
type MethodFn = MethodFnBase & {
sse: MethodFnServerSentEvents
}
type RequestFn = <
TData = unknown,
TError = unknown,
ThrowOnError extends boolean = false,
TResponseStyle extends ResponseStyle = "fields",
>(
options: Omit<RequestOptions<TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TResponseStyle, ThrowOnError>>, "method">,
options: Omit<RequestOptions<TData, TResponseStyle, ThrowOnError>, "method"> &
Pick<Required<RequestOptions<TData, TResponseStyle, ThrowOnError>>, "method">,
) => RequestResult<TData, TError, ThrowOnError, TResponseStyle>
type BuildUrlFn = <
@ -143,7 +172,7 @@ type BuildUrlFn = <
) => string
export type Client = CoreClient<RequestFn, Config, MethodFn, BuildUrlFn> & {
interceptors: Middleware<Request, Response, unknown, RequestOptions>
interceptors: Middleware<Request, Response, unknown, ResolvedRequestOptions>
}
/**
@ -171,8 +200,10 @@ type OmitKeys<T, K> = Pick<T, Exclude<keyof T, K>>
export type Options<
TData extends TDataShape = TDataShape,
ThrowOnError extends boolean = boolean,
TResponse = unknown,
TResponseStyle extends ResponseStyle = "fields",
> = OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> & Omit<TData, "url">
> = OmitKeys<RequestOptions<TResponse, TResponseStyle, ThrowOnError>, "body" | "path" | "query" | "url"> &
Omit<TData, "url">
export type OptionsLegacyParser<
TData = unknown,
@ -180,12 +211,12 @@ export type OptionsLegacyParser<
TResponseStyle extends ResponseStyle = "fields",
> = TData extends { body?: any }
? TData extends { headers?: any }
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & TData
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "body" | "url"> &
? OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body" | "headers" | "url"> & TData
: OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "headers">
Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "headers">
: TData extends { headers?: any }
? OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "headers" | "url"> &
? OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "headers" | "url"> &
TData &
Pick<RequestOptions<TResponseStyle, ThrowOnError>, "body">
: OmitKeys<RequestOptions<TResponseStyle, ThrowOnError>, "url"> & TData
Pick<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "body">
: OmitKeys<RequestOptions<unknown, TResponseStyle, ThrowOnError>, "url"> & TData

View file

@ -1,84 +1,11 @@
import { getAuthToken } from "../core/auth.js"
import type { QuerySerializer, QuerySerializerOptions } from "../core/bodySerializer.js"
import { jsonBodySerializer } from "../core/bodySerializer.js"
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.js"
import type { Client, ClientOptions, Config, RequestOptions } from "./types.js"
// This file is auto-generated by @hey-api/openapi-ts
interface PathSerializer {
path: Record<string, unknown>
url: string
}
const PATH_PARAM_RE = /\{[^{}]+\}/g
type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited"
type MatrixStyle = "label" | "matrix" | "simple"
type ArraySeparatorStyle = ArrayStyle | MatrixStyle
const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url
const matches = _url.match(PATH_PARAM_RE)
if (matches) {
for (const match of matches) {
let explode = false
let name = match.substring(1, match.length - 1)
let style: ArraySeparatorStyle = "simple"
if (name.endsWith("*")) {
explode = true
name = name.substring(0, name.length - 1)
}
if (name.startsWith(".")) {
name = name.substring(1)
style = "label"
} else if (name.startsWith(";")) {
name = name.substring(1)
style = "matrix"
}
const value = path[name]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }))
continue
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
)
continue
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
)
continue
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string))
url = url.replace(match, replaceValue)
}
}
return url
}
import { getAuthToken } from "../core/auth.gen.js"
import type { QuerySerializerOptions } from "../core/bodySerializer.gen.js"
import { jsonBodySerializer } from "../core/bodySerializer.gen.js"
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam } from "../core/pathSerializer.gen.js"
import { getUrl } from "../core/utils.gen.js"
import type { Client, ClientOptions, Config, RequestOptions } from "./types.gen.js"
export const createQuerySerializer = <T = unknown>({ allowReserved, array, object }: QuerySerializerOptions = {}) => {
const querySerializer = (queryParams: T) => {
@ -161,6 +88,21 @@ export const getParseAs = (contentType: string | null): Exclude<Config["parseAs"
return
}
const checkForExistence = (
options: Pick<RequestOptions, "auth" | "query"> & {
headers: Headers
},
name?: string,
): boolean => {
if (!name) {
return false
}
if (options.headers.has(name) || options.query?.[name] || options.headers.get("Cookie")?.includes(`${name}=`)) {
return true
}
return false
}
export const setAuthParams = async ({
security,
...options
@ -169,6 +111,10 @@ export const setAuthParams = async ({
headers: Headers
}) => {
for (const auth of security) {
if (checkForExistence(options, auth.name)) {
continue
}
const token = await getAuthToken(auth, options.auth)
if (!token) {
@ -192,13 +138,11 @@ export const setAuthParams = async ({
options.headers.set(name, token)
break
}
return
}
}
export const buildUrl: Client["buildUrl"] = (options) => {
const url = getUrl({
export const buildUrl: Client["buildUrl"] = (options) =>
getUrl({
baseUrl: options.baseUrl as string,
path: options.path,
query: options.query,
@ -208,36 +152,6 @@ export const buildUrl: Client["buildUrl"] = (options) => {
: createQuerySerializer(options.querySerializer),
url: options.url,
})
return url
}
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string
path?: Record<string, unknown>
query?: Record<string, unknown>
querySerializer: QuerySerializer
url: string
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrl
if (path) {
url = defaultPathSerializer({ path, url })
}
let search = query ? querySerializer(query) : ""
if (search.startsWith("?")) {
search = search.substring(1)
}
if (search) {
url += `?${search}`
}
return url
}
export const mergeConfigs = (a: Config, b: Config): Config => {
const config = { ...a, ...b }

View file

@ -1,3 +1,5 @@
// This file is auto-generated by @hey-api/openapi-ts
export type AuthToken = string | undefined
export interface Auth {

View file

@ -1,4 +1,6 @@
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.js"
// This file is auto-generated by @hey-api/openapi-ts
import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js"
export type QuerySerializer = (query: Record<string, unknown>) => string
@ -13,6 +15,8 @@ export interface QuerySerializerOptions {
const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => {
if (typeof value === "string" || value instanceof Blob) {
data.append(key, value)
} else if (value instanceof Date) {
data.append(key, value.toISOString())
} else {
data.append(key, JSON.stringify(value))
}

View file

@ -1,3 +1,5 @@
// This file is auto-generated by @hey-api/openapi-ts
type Slot = "body" | "headers" | "path" | "query"
export type Field =

View file

@ -1,3 +1,5 @@
// This file is auto-generated by @hey-api/openapi-ts
interface SerializeOptions<T> extends SerializePrimitiveOptions, SerializerOptions<T> {}
interface SerializePrimitiveOptions {

View file

@ -0,0 +1,210 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { Config } from "./types.gen.js"
export type ServerSentEventsOptions<TData = unknown> = Omit<RequestInit, "method"> &
Pick<Config, "method" | "responseTransformer" | "responseValidator"> & {
/**
* Callback invoked when a network or parsing error occurs during streaming.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param error The error that occurred.
*/
onSseError?: (error: unknown) => void
/**
* Callback invoked when an event is streamed from the server.
*
* This option applies only if the endpoint returns a stream of events.
*
* @param event Event streamed from the server.
* @returns Nothing (void).
*/
onSseEvent?: (event: StreamEvent<TData>) => void
/**
* Default retry delay in milliseconds.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 3000
*/
sseDefaultRetryDelay?: number
/**
* Maximum number of retry attempts before giving up.
*/
sseMaxRetryAttempts?: number
/**
* Maximum retry delay in milliseconds.
*
* Applies only when exponential backoff is used.
*
* This option applies only if the endpoint returns a stream of events.
*
* @default 30000
*/
sseMaxRetryDelay?: number
/**
* Optional sleep function for retry backoff.
*
* Defaults to using `setTimeout`.
*/
sseSleepFn?: (ms: number) => Promise<void>
url: string
}
export interface StreamEvent<TData = unknown> {
data: TData
event?: string
id?: string
retry?: number
}
export type ServerSentEventsResult<TData = unknown, TReturn = void, TNext = unknown> = {
stream: AsyncGenerator<TData extends Record<string, unknown> ? TData[keyof TData] : TData, TReturn, TNext>
}
export const createSseClient = <TData = unknown>({
onSseError,
onSseEvent,
responseTransformer,
responseValidator,
sseDefaultRetryDelay,
sseMaxRetryAttempts,
sseMaxRetryDelay,
sseSleepFn,
url,
...options
}: ServerSentEventsOptions): ServerSentEventsResult<TData> => {
let lastEventId: string | undefined
const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
const createStream = async function* () {
let retryDelay: number = sseDefaultRetryDelay ?? 3000
let attempt = 0
const signal = options.signal ?? new AbortController().signal
while (true) {
if (signal.aborted) break
attempt++
const headers =
options.headers instanceof Headers
? options.headers
: new Headers(options.headers as Record<string, string> | undefined)
if (lastEventId !== undefined) {
headers.set("Last-Event-ID", lastEventId)
}
try {
const response = await fetch(url, { ...options, headers, signal })
if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`)
if (!response.body) throw new Error("No body in SSE response")
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
const abortHandler = () => {
try {
reader.cancel()
} catch {
// noop
}
}
signal.addEventListener("abort", abortHandler)
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += value
const chunks = buffer.split("\n\n")
buffer = chunks.pop() ?? ""
for (const chunk of chunks) {
const lines = chunk.split("\n")
const dataLines: Array<string> = []
let eventName: string | undefined
for (const line of lines) {
if (line.startsWith("data:")) {
dataLines.push(line.replace(/^data:\s*/, ""))
} else if (line.startsWith("event:")) {
eventName = line.replace(/^event:\s*/, "")
} else if (line.startsWith("id:")) {
lastEventId = line.replace(/^id:\s*/, "")
} else if (line.startsWith("retry:")) {
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10)
if (!Number.isNaN(parsed)) {
retryDelay = parsed
}
}
}
let data: unknown
let parsedJson = false
if (dataLines.length) {
const rawData = dataLines.join("\n")
try {
data = JSON.parse(rawData)
parsedJson = true
} catch {
data = rawData
}
}
if (parsedJson) {
if (responseValidator) {
await responseValidator(data)
}
if (responseTransformer) {
data = await responseTransformer(data)
}
}
onSseEvent?.({
data,
event: eventName,
id: lastEventId,
retry: retryDelay,
})
if (dataLines.length) {
yield data as any
}
}
}
} finally {
signal.removeEventListener("abort", abortHandler)
reader.releaseLock()
}
break // exit loop on normal completion
} catch (error) {
// connection failed or aborted; retry after delay
onSseError?.(error)
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
break // stop after firing error
}
// exponential backoff: double retry each attempt, cap at 30s
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000)
await sleep(backoff)
}
}
}
const stream = createStream()
return { stream }
}

View file

@ -1,5 +1,7 @@
import type { Auth, AuthToken } from "./auth.js"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.js"
// This file is auto-generated by @hey-api/openapi-ts
import type { Auth, AuthToken } from "./auth.gen.js"
import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js"
export interface Client<RequestFn = never, Config = unknown, MethodFn = never, BuildUrlFn = never> {
/**

View file

@ -0,0 +1,109 @@
// This file is auto-generated by @hey-api/openapi-ts
import type { QuerySerializer } from "./bodySerializer.gen.js"
import {
type ArraySeparatorStyle,
serializeArrayParam,
serializeObjectParam,
serializePrimitiveParam,
} from "./pathSerializer.gen.js"
export interface PathSerializer {
path: Record<string, unknown>
url: string
}
export const PATH_PARAM_RE = /\{[^{}]+\}/g
export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => {
let url = _url
const matches = _url.match(PATH_PARAM_RE)
if (matches) {
for (const match of matches) {
let explode = false
let name = match.substring(1, match.length - 1)
let style: ArraySeparatorStyle = "simple"
if (name.endsWith("*")) {
explode = true
name = name.substring(0, name.length - 1)
}
if (name.startsWith(".")) {
name = name.substring(1)
style = "label"
} else if (name.startsWith(";")) {
name = name.substring(1)
style = "matrix"
}
const value = path[name]
if (value === undefined || value === null) {
continue
}
if (Array.isArray(value)) {
url = url.replace(match, serializeArrayParam({ explode, name, style, value }))
continue
}
if (typeof value === "object") {
url = url.replace(
match,
serializeObjectParam({
explode,
name,
style,
value: value as Record<string, unknown>,
valueOnly: true,
}),
)
continue
}
if (style === "matrix") {
url = url.replace(
match,
`;${serializePrimitiveParam({
name,
value: value as string,
})}`,
)
continue
}
const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string))
url = url.replace(match, replaceValue)
}
}
return url
}
export const getUrl = ({
baseUrl,
path,
query,
querySerializer,
url: _url,
}: {
baseUrl?: string
path?: Record<string, unknown>
query?: Record<string, unknown>
querySerializer: QuerySerializer
url: string
}) => {
const pathUrl = _url.startsWith("/") ? _url : `/${_url}`
let url = (baseUrl ?? "") + pathUrl
if (path) {
url = defaultPathSerializer({ path, url })
}
let search = query ? querySerializer(query) : ""
if (search.startsWith("?")) {
search = search.substring(1)
}
if (search) {
url += `?${search}`
}
return url
}

View file

@ -59,6 +59,8 @@ import type {
FindFilesResponses,
FindSymbolsData,
FindSymbolsResponses,
FileListData,
FileListResponses,
FileReadData,
FileReadResponses,
FileStatusData,
@ -123,7 +125,7 @@ class Event extends _HeyApiClient {
* Get events
*/
public subscribe<ThrowOnError extends boolean = false>(options?: Options<EventSubscribeData, ThrowOnError>) {
return (options?.client ?? this._client).get<EventSubscribeResponses, unknown, ThrowOnError>({
return (options?.client ?? this._client).get.sse<EventSubscribeResponses, unknown, ThrowOnError>({
url: "/event",
...options,
})
@ -457,12 +459,22 @@ class Find extends _HeyApiClient {
}
class File extends _HeyApiClient {
/**
* List files and directories
*/
public list<ThrowOnError extends boolean = false>(options: Options<FileListData, ThrowOnError>) {
return (options.client ?? this._client).get<FileListResponses, unknown, ThrowOnError>({
url: "/file",
...options,
})
}
/**
* Read a file
*/
public read<ThrowOnError extends boolean = false>(options: Options<FileReadData, ThrowOnError>) {
return (options.client ?? this._client).get<FileReadResponses, unknown, ThrowOnError>({
url: "/file",
url: "/file/content",
...options,
})
}

View file

@ -554,6 +554,7 @@ export type App = {
hostname: string
git: boolean
path: {
home: string
config: string
data: string
root: string
@ -587,6 +588,9 @@ export type Config = {
*/
scroll_speed: number
}
/**
* Command configuration, see https://opencode.ai/docs/commands
*/
command?: {
[key: string]: {
template: string
@ -1137,6 +1141,13 @@ export type Symbol = {
}
}
export type FileNode = {
name: string
path: string
type: "file" | "directory"
ignored: boolean
}
export type File = {
path: string
added: number
@ -1803,7 +1814,7 @@ export type FindSymbolsResponses = {
export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses]
export type FileReadData = {
export type FileListData = {
body?: never
path?: never
query: {
@ -1812,6 +1823,24 @@ export type FileReadData = {
url: "/file"
}
export type FileListResponses = {
/**
* Files and directories
*/
200: Array<FileNode>
}
export type FileListResponse = FileListResponses[keyof FileListResponses]
export type FileReadData = {
body?: never
path?: never
query: {
path: string
}
url: "/file/content"
}
export type FileReadResponses = {
/**
* File content

View file

@ -48,7 +48,6 @@ resources:
app:
models:
app: App
logLevel: LogLevel
provider: Provider
model: Model
agent: Agent
@ -61,7 +60,6 @@ resources:
find:
models:
match: Match
symbol: Symbol
methods:
text: get /find
@ -71,8 +69,11 @@ resources:
file:
models:
file: File
fileNode: FileNode
methods:
read: get /file
list: get /file
read: get /file/content
status: get /file/status
config:

View file

@ -820,11 +820,13 @@ func (a *App) SendCommand(ctx context.Context, command string, args string) (*Ap
opencode.SessionCommandParams{
Command: opencode.F(command),
Arguments: opencode.F(args),
Agent: opencode.F(a.Agents[a.AgentIndex].Name),
Model: opencode.F(a.State.Provider + "/" + a.State.Model),
},
)
if err != nil {
slog.Error("Failed to execute command", "error", err)
return toast.NewErrorToast("Failed to execute command")
return toast.NewErrorToast(fmt.Sprintf("Failed to execute command: %v", err))()
}
return nil
})
@ -856,7 +858,7 @@ func (a *App) SendShell(ctx context.Context, command string) (*App, tea.Cmd) {
)
if err != nil {
slog.Error("Failed to submit shell command", "error", err)
return toast.NewErrorToast("Failed to submit shell command")()
return toast.NewErrorToast(fmt.Sprintf("Failed to submit shell command: %v", err))()
}
return nil
})

View file

@ -92,6 +92,12 @@ func (r CommandRegistry) Sorted() []Command {
if b.Name == AppExitCommand {
return -1
}
if a.Custom && !b.Custom {
return 1
}
if !a.Custom && b.Custom {
return -1
}
return strings.Compare(string(a.Name), string(b.Name))
})

View file

@ -92,7 +92,41 @@ func (c *CommandCompletionProvider) GetChildEntries(
}
matches := fuzzy.RankFindFold(query, commandNames)
sort.Sort(matches)
// Custom sort to prioritize exact matches
sort.Slice(matches, func(i, j int) bool {
// Check for exact match (case-insensitive)
iExact := strings.EqualFold(matches[i].Target, query)
jExact := strings.EqualFold(matches[j].Target, query)
// Exact matches come first
if iExact && !jExact {
return true
}
if !iExact && jExact {
return false
}
// Check for prefix match (case-insensitive)
iPrefix := strings.HasPrefix(strings.ToLower(matches[i].Target), strings.ToLower(query))
jPrefix := strings.HasPrefix(strings.ToLower(matches[j].Target), strings.ToLower(query))
// Prefix matches come before fuzzy matches
if iPrefix && !jPrefix {
return true
}
if !iPrefix && jPrefix {
return false
}
// Otherwise, sort by fuzzy match score (lower distance is better)
if matches[i].Distance != matches[j].Distance {
return matches[i].Distance < matches[j].Distance
}
// If distances are equal, sort by original index (stable sort)
return matches[i].OriginalIndex < matches[j].OriginalIndex
})
// Convert matches to completion items, deduplicating by command name
items := []CompletionSuggestion{}

View file

@ -489,11 +489,22 @@ func (m *editorComponent) Submit() (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
if strings.HasPrefix(value, "/") {
value = value[1:]
commandName := strings.Split(value, " ")[0]
// Expand attachments in the value to get actual content
expandedValue := value
attachments := m.textarea.GetAttachments()
for _, att := range attachments {
if att.Type == "text" && att.Source != nil {
if textSource, ok := att.Source.(*attachment.TextSource); ok {
expandedValue = strings.Replace(expandedValue, att.Display, textSource.Value, 1)
}
}
}
expandedValue = expandedValue[1:] // Remove the "/"
commandName := strings.Split(expandedValue, " ")[0]
command := m.app.Commands[commands.CommandName(commandName)]
if command.Custom {
args := strings.TrimPrefix(value, command.PrimaryTrigger()+" ")
args := strings.TrimPrefix(expandedValue, command.PrimaryTrigger()+" ")
cmds = append(
cmds,
util.CmdHandler(app.SendCommand{Command: string(command.Name), Args: args}),

View file

@ -1173,10 +1173,10 @@ func (m *messagesComponent) UndoLastMessage() (tea.Model, tea.Cmd) {
)
if err != nil {
slog.Error("Failed to undo message", "error", err)
return toast.NewErrorToast("Failed to undo message")
return toast.NewErrorToast("Failed to undo message")()
}
if response == nil {
return toast.NewErrorToast("Failed to undo message")
return toast.NewErrorToast("Failed to undo message")()
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}
@ -1241,10 +1241,10 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
)
if err != nil {
slog.Error("Failed to unrevert session", "error", err)
return toast.NewErrorToast("Failed to redo message")
return toast.NewErrorToast("Failed to redo message")()
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
return toast.NewErrorToast("Failed to redo message")()
}
return app.SessionUnrevertedMsg{Session: *response}
}
@ -1261,10 +1261,10 @@ func (m *messagesComponent) RedoLastMessage() (tea.Model, tea.Cmd) {
)
if err != nil {
slog.Error("Failed to redo message", "error", err)
return toast.NewErrorToast("Failed to redo message")
return toast.NewErrorToast("Failed to redo message")()
}
if response == nil {
return toast.NewErrorToast("Failed to redo message")
return toast.NewErrorToast("Failed to redo message")()
}
return app.MessageRevertedMsg{Session: *response, Message: revertedMessage}
}

View file

@ -134,7 +134,7 @@ func (a Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
)
if err != nil {
slog.Error("Failed to respond to permission request", "error", err)
return toast.NewErrorToast("Failed to respond to permission request")
return toast.NewErrorToast("Failed to respond to permission request")()
}
slog.Debug("Responded to permission request", "response", resp)
return nil
@ -915,9 +915,10 @@ func (a Model) Cleanup() {
func (a Model) home() (string, int, int) {
t := theme.CurrentTheme()
effectiveWidth := a.width - 4
baseStyle := styles.NewStyle().Background(t.Background())
baseStyle := styles.NewStyle().Foreground(t.Text()).Background(t.Background())
base := baseStyle.Render
muted := styles.NewStyle().Foreground(t.TextMuted()).Background(t.Background()).Render
highlight := styles.NewStyle().Foreground(t.Accent()).Background(t.Background()).Render
open := `
@ -952,9 +953,9 @@ func (a Model) home() (string, int, int) {
)
// Use limit of 4 for vscode, 6 for others
limit := 6
limit := 4
if util.IsVSCode() {
limit = 4
limit = 2
}
showVscode := util.IsVSCode()
@ -971,15 +972,23 @@ func (a Model) home() (string, int, int) {
styles.WhitespaceStyle(t.Background()),
)
grok := highlight("Grok Code is free for a limited time")
grok = lipgloss.PlaceHorizontal(
effectiveWidth,
lipgloss.Center,
grok,
styles.WhitespaceStyle(t.Background()),
)
lines := []string{}
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, logoAndVersion)
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, cmds)
lines = append(lines, "")
lines = append(lines, "")
lines = append(lines, grok)
lines = append(lines, "")
mainHeight := lipgloss.Height(strings.Join(lines, "\n"))

View file

@ -11,7 +11,10 @@ import (
"github.com/sst/opencode/internal/styles"
)
var shimmerStart = time.Now()
var (
shimmerStart = time.Now()
trueColorSupport = hasTrueColor()
)
// Shimmer renders text with a moving foreground highlight.
// bg is the background color, dim is the base text color, bright is the highlight color.
@ -32,7 +35,7 @@ func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat
elapsed := time.Since(shimmerStart).Seconds()
pos := (math.Mod(elapsed, sweep) / sweep) * period
half := 4.0
half := 2.0
type seg struct {
useHex bool
@ -41,60 +44,52 @@ func Shimmer(s string, bg compat.AdaptiveColor, _ compat.AdaptiveColor, _ compat
faint bool
text string
}
var segs []seg
segs := make([]seg, 0, n/4)
useHex := hasTrueColor()
useHex := trueColorSupport
for i, r := range runes {
ip := float64(i + pad)
dist := math.Abs(ip - pos)
t := 0.0
if dist <= half {
x := math.Pi * (dist / half)
t = 0.5 * (1.0 + math.Cos(x))
}
// Cosine brightness: base + amp*t (quantized for grouping)
base := 0.55
amp := 0.45
brightness := base
if t > 0 {
brightness = base + amp*t
}
lvl := int(math.Round(brightness * 255.0))
if !useHex {
step := 24 // ~11 steps across range for non-truecolor
lvl = int(math.Round(float64(lvl)/float64(step))) * step
}
bold := lvl >= 208
faint := lvl <= 128
// truecolor if possible; else fallback to modifiers only
bold := false
faint := true
hex := ""
if useHex {
if lvl < 0 {
lvl = 0
if dist <= half {
// Simple 3-level brightness based on distance
if dist <= half/3 {
// Center: brightest
bold = true
faint = false
if useHex {
hex = "#ffffff"
}
} else {
// Edge: medium bright
bold = false
faint = false
if useHex {
hex = "#cccccc"
}
}
if lvl > 255 {
lvl = 255
}
hex = rgbHex(lvl, lvl, lvl)
}
if len(segs) == 0 {
if len(segs) == 0 ||
segs[len(segs)-1].useHex != useHex ||
segs[len(segs)-1].hex != hex ||
segs[len(segs)-1].bold != bold ||
segs[len(segs)-1].faint != faint {
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
} else {
last := &segs[len(segs)-1]
if last.useHex == useHex && last.hex == hex && last.bold == bold && last.faint == faint {
last.text += string(r)
} else {
segs = append(segs, seg{useHex: useHex, hex: hex, bold: bold, faint: faint, text: string(r)})
}
segs[len(segs)-1].text += string(r)
}
}
baseStyle := styles.NewStyle().Background(bg)
var b strings.Builder
b.Grow(len(s) * 2)
for _, g := range segs {
st := styles.NewStyle().Background(bg)
st := baseStyle
if g.useHex && g.hex != "" {
c := compat.AdaptiveColor{Dark: lipgloss.Color(g.hex), Light: lipgloss.Color(g.hex)}
st = st.Foreground(c)

View file

@ -1,7 +1,7 @@
{
"name": "@opencode/web",
"type": "module",
"version": "0.5.18",
"version": "0.5.29",
"scripts": {
"dev": "astro dev",
"dev:remote": "sst shell --stage=dev --target=Web astro dev",
@ -30,7 +30,7 @@
"remeda": "2.26.0",
"sharp": "0.32.5",
"shiki": "3.4.2",
"solid-js": "1.9.7",
"solid-js": "catalog:",
"toolbeam-docs-theme": "0.4.6"
},
"devDependencies": {

View file

@ -1,6 +1,7 @@
import style from "./content-text.module.css"
import { createSignal } from "solid-js"
import { createOverflow } from "./common"
import { CopyButton } from "./copy-button"
interface Props {
text: string
@ -30,6 +31,7 @@ export function ContentText(props: Props) {
{expanded() ? "Show less" : "Show more"}
</button>
)}
<CopyButton text={props.text} />
</div>
)
}

View file

@ -9,7 +9,6 @@
background: none;
border: none;
padding: 0.125rem;
background-color: var(--sl-color-bg);
color: var(--sl-color-text-secondary);
svg {

View file

@ -126,6 +126,12 @@
gap: 1rem;
flex-grow: 1;
max-width: var(--md-tool-width);
position: relative;
[data-component="copy-button"] {
top: 0.5rem;
right: calc(0.5rem - 1px);
}
}
[data-component="assistant-reasoning"] {

View file

@ -58,12 +58,11 @@ Build is the **default** primary agent with all tools enabled. This is the stand
_Mode_: `primary`
A restricted agent designed for planning and analysis. In the plan agent, the following tools are disabled by default:
A restricted agent designed for planning and analysis. We use a permission system to give you more control and prevent unintended changes.
By default, all of the following are set to `ask`:
- `write` - Cannot create new files
- `edit` - Cannot modify existing files
- `patch` - Cannot apply patches
- `bash` - Cannot execute shell commands
- `file edits`: All writes, patches, and edits
- `bash`: All bash commands
This agent is useful when you want the LLM to analyze code, suggest changes, or create plans without making any actual modifications to your codebase.

View file

@ -3,7 +3,13 @@ title: Commands
description: Create custom commands for repetitive tasks.
---
Define custom commands to automate repetitive coding tasks.
Custom commands let you specify a prompt you want to run when that command is executed in the TUI.
```bash frame="none"
/my-command
```
Custom commands are in addition to the built-in commands like `/init`, `/undo`, `/redo`, `/share`, `/help`. [Learn more](/docs/tui#commands).
---
@ -34,12 +40,78 @@ Use the command by typing `/` followed by the command name.
---
## Use arguments
## Configure
You can add custom commands through the opencode config or by creating markdown files in the `command/` directory.
---
### JSON
Use the `command` option in your opencode [config](/docs/config):
```json title="opencode.jsonc" {4-12}
{
"$schema": "https://opencode.ai/config.json",
"command": {
// This becomes the name of the command
"test": {
// This is the prompt that will be sent to the LLM
"template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.",
// This is show as the description in the TUI
"description": "Run tests with coverage",
"agent": "build",
"model": "anthropic/claude-3-5-sonnet-20241022"
},
}
}
```
Now you can run this command in the TUI:
```bash frame="none"
/test
```
---
### Markdown
You can also define commands using markdown files. Place them in:
- Global: `~/.config/opencode/command/`
- Per-project: `.opencode/command/`
```markdown title="~/.config/opencode/command/test.md"
---
description: Run tests with coverage
agent: build
model: anthropic/claude-3-5-sonnet-20241022
---
Run the full test suite with coverage report and show any failures.
Focus on the failing tests and suggest fixes.
```
The markdown file name becomes the command name. For example, `test.md` lets
you run:
```bash frame="none"
/test
```
---
## Prompt config
The prompts for the custom commands support several special placeholders and syntax.
---
### Arguments
Pass arguments to commands using the `$ARGUMENTS` placeholder.
Create `.opencode/command/component.md`:
```md title=".opencode/command/component.md"
---
description: Create a new component
@ -52,16 +124,18 @@ Include proper typing and basic structure.
Run the command with arguments:
```bash frame="none"
"/component Button"
/component Button
```
And `$ARGUMENTS` will be replaced with `Button`.
---
## Inject shell output
### Shell output
Use `!command` to inject shell command output into your prompt.
Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into your prompt.
Create `.opencode/command/analyze-coverage.md`:
For example, to create a custom command that analyzes test coverage:
```md title=".opencode/command/analyze-coverage.md"
---
@ -69,12 +143,12 @@ description: Analyze test coverage
---
Here are the current test results:
`!npm test`
!`npm test`
Based on these results, suggest improvements to increase coverage.
```
Create `.opencode/command/review-changes.md`:
Or to review recent changes:
```md title=".opencode/command/review-changes.md"
---
@ -82,7 +156,7 @@ description: Review recent changes
---
Recent git commits:
`!git log --oneline -10`
!`git log --oneline -10`
Review these changes and suggest any improvements.
```
@ -91,12 +165,10 @@ Commands run in your project's root directory and their output becomes part of t
---
## Reference files
### File references
Include files in your command using `@` followed by the filename.
Create `.opencode/command/review-component.md`:
```md title=".opencode/command/review-component.md"
---
description: Review component
@ -110,47 +182,90 @@ The file content gets included in the prompt automatically.
---
## Command properties
## Options
Configure commands with these optional frontmatter properties:
Let's look at the configuration options in detail.
- **description**: Brief explanation of what the command does
- **agent**: Agent to use (defaults to "build")
- **model**: Specific model to use for this command
Create `.opencode/command/code-review.md`:
```md title=".opencode/command/code-review.md"
---
description: Code review assistant
agent: build
model: anthropic/claude-3-5-sonnet-20241022
---
Review the code for best practices and suggest improvements.
### Template
The `template` option defines the prompt that will be sent to the LLM when the command is executed.
```json title="opencode.json"
{
"command": {
"test": {
"template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes."
}
}
}
```
---
## Command directory
Store command files in these locations:
- `.opencode/command/` - Project-specific commands
- `command/` - Global commands in config directory
Project commands take precedence over global ones.
This is a **required** config option.
---
## Built-in commands
### Description
opencode includes several built-in commands:
Use the `description` option to provide a brief description of what the command does.
- `/init` - Initialize project and create AGENTS.md
- `/undo` - Revert the last changes
- `/redo` - Restore reverted changes
- `/share` - Share the current conversation
- `/help` - Show available commands and keybinds
```json title="opencode.json"
{
"command": {
"test": {
"description": "Run tests with coverage"
}
}
}
```
Use `/help` to see all available commands in your setup.
This is shown as the description in the TUI when you type in the command.
---
### Agent
Use the `agent` config to optionally specify which [agent](/docs/agents) should execute this command.
```json title="opencode.json"
{
"command": {
"review": {
"agent": "plan"
}
}
}
```
This is an **optional** config option. If not specified, defaults to "build".
---
### Model
Use the `model` config to override the default model for this command.
```json title="opencode.json"
{
"command": {
"analyze": {
"model": "anthropic/claude-3-5-sonnet-20241022"
}
}
}
```
This is an **optional** config option.
---
## Built-in
opencode includes several built-in commands like `/init`, `/undo`, `/redo`, `/share`, `/help`; [learn more](/docs/tui#commands).
:::note
Custom commands can override built-in commands.
:::
If you define a custom command with the same name, it will override the built-in command.

View file

@ -152,6 +152,32 @@ By default, sharing is set to manual mode where you need to explicitly share con
---
### Commands
You can configure custom commands for repetitive tasks through the `command` option.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
"command": {
"test": {
"template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes.",
"description": "Run tests with coverage",
"agent": "build",
"model": "anthropic/claude-3-5-sonnet-20241022"
},
"component": {
"template": "Create a new React component named $ARGUMENTS with TypeScript support.\nInclude proper typing and basic structure.",
"description": "Create a new component"
}
}
}
```
You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands).
---
### Keybinds
You can customize your keybinds through the `keybinds` option.
@ -220,6 +246,11 @@ You can configure permissions to control what AI agents can do in your codebase
}
```
This allows you to configure explicit approval requirements for sensitive operations:
- `edit` - Controls whether file editing operations require user approval (`"ask"` or `"allow"`)
- `bash` - Controls whether bash commands require user approval (can be `"ask"`/`"allow"` or a pattern map)
[Learn more about permissions here](/docs/permissions).
---
@ -259,13 +290,6 @@ about rules here](/docs/rules).
You can disable providers that are loaded automatically through the `disabled_providers` option. This is useful when you want to prevent certain providers from being loaded even if their credentials are available.
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- It won't be loaded even if environment variables are set
- It won't be loaded even if API keys are configured through `opencode auth login`
- The provider's models won't appear in the model selection list
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
@ -273,12 +297,11 @@ The `disabled_providers` option accepts an array of provider IDs. When a provide
}
```
The permissions system allows you to configure explicit approval requirements for sensitive operations:
The `disabled_providers` option accepts an array of provider IDs. When a provider is disabled:
- `edit` - Controls whether file editing operations require user approval (`"ask"` or `"allow"`)
- `bash` - Controls whether bash commands require user approval (can be `"ask"`/`"allow"` or a pattern map)
[Learn more about permissions here](/docs/permissions).
- It won't be loaded even if environment variables are set.
- It won't be loaded even if API keys are configured through `opencode auth login`.
- The provider's models won't appear in the model selection list.
---

View file

@ -9,7 +9,7 @@ you to use opencode at your organization.
To get started, we recommend:
1. Do a trial internally with your team.
2. [**Contact us**](mailto:hello@sst.dev) to discuss pricing and implementation options.
2. [**Contact us**](mailto:hello@anoma.ly) to discuss pricing and implementation options.
---
@ -55,7 +55,7 @@ We recommend you disable this for your trial.
## Deployment
Once you have completed your trial and you are ready to self-host opencode at
your organization, you can [**contact us**](mailto:hello@sst.dev) to discuss
your organization, you can [**contact us**](mailto:hello@anoma.ly) to discuss
pricing and implementation options.
---

View file

@ -11,19 +11,26 @@ opencode integrates with your Language Server Protocol (LSP) to help the LLM int
opencode comes with several built-in LSP servers for popular languages:
| LSP Server | Extensions | Requirements |
| ---------- | -------------------------------------------- | ----------------------------------- |
| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | `typescript` dependency in project |
| eslint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | `eslint` dependency in project |
| gopls | .go | `go` command available |
| ruby-lsp | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available |
| pyright | .py, .pyi | `pyright` dependency installed |
| elixir-ls | .ex, .exs | `elixir` command available |
| zls | .zig, .zon | `zig` command available |
| csharp | .cs | `.NET SDK` installed |
| LSP Server | Extensions | Requirements |
| ---------- | ---------------------------------------------------- | ----------------------------------- |
| typescript | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts | `typescript` dependency in project |
| eslint | .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts, .cts, .vue | `eslint` dependency in project |
| gopls | .go | `go` command available |
| ruby-lsp | .rb, .rake, .gemspec, .ru | `ruby` and `gem` commands available |
| pyright | .py, .pyi | `pyright` dependency installed |
| elixir-ls | .ex, .exs | `elixir` command available |
| zls | .zig, .zon | `zig` command available |
| csharp | .cs | `.NET SDK` installed |
| vue | .vue | Auto-installs for Vue projects |
| rust | .rs | `rust-analyzer` command available |
| clangd | .c, .cpp, .cc, .cxx, .c++, .h, .hpp, .hh, .hxx, .h++ | Auto-installs for C/C++ projects |
LSP servers are automatically enabled when one of the above file extensions are detected and the requirements are met.
:::note
You can disable automatic LSP server downloads by setting the `OPENCODE_DISABLE_LSP_DOWNLOAD` environment variable to `true`.
:::
---
## How It Works

View file

@ -91,3 +91,38 @@ Local and remote servers can be used together within the same `mcp` config objec
}
}
}
```
---
## Per agent
If you have a large number of MCP servers you may want to only enable them per
agent and disable them globally. To do this:
1. Configure the MCP server.
2. Disable it as a tool globally.
3. In your [agent config](/docs/agents#tools) enable the MCP server as a tool.
```json title="opencode.json" {11, 14-17}
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"my-mcp": {
"type": "local",
"command": ["bun", "x", "my-mcp-command"],
"enabled": true
}
},
"tools": {
"my-mcp*": false
},
"agent": {
"my-agent": {
"tools": {
"my-mcp*": true
}
}
}
}
```

View file

@ -68,7 +68,7 @@ If you've configured a [custom provider](/docs/providers#custom), the `provider_
You can globally configure a model's options through the config.
```jsonc title="opencode.jsonc" {7-11}
```jsonc title="opencode.jsonc" {7-12,19-24}
{
"$schema": "https://opencode.ai/config.json",
"provider": {
@ -79,16 +79,28 @@ You can globally configure a model's options through the config.
"reasoningEffort": "high",
"textVerbosity": "low",
"reasoningSummary": "auto",
"include": ["reasoning.encrypted_content"]
}
}
}
}
}
"include": ["reasoning.encrypted_content"],
},
},
},
},
"anthropic": {
"models": {
"claude-sonnet-4-20250514": {
"options": {
"thinking": {
"type": "enabled",
"budgetTokens": 16000,
},
},
},
},
},
},
}
```
Here we are setting global options for the `gpt-5` model when used through the `openai` provider.
Here we're configuring global settings for two models: `gpt-5` when accessed via the `openai` provider, and `claude-sonnet-4-20250514` when accessed via the `anthropic` provider.
You can also configure these options for any agents that you are using. The agent config overrides any global options here. [Learn more](/docs/agents/#additional).

View file

@ -759,6 +759,23 @@ In this example:
---
### xAI
For a limited time, you can use xAI's Grok Code for free with opencode.
:::tip
Grok Code is available for free for a limited time on opencode.
:::
1. Make sure you are on the latest version of opencode.
2. Run the `/models` command and select **Grok Code Free**.
As a part of the trial period, the xAI team will be using the request logs to
monitor and improve Grok Code.
---
### Z.AI
1. Head over to the [Z.AI API console](https://z.ai/manage-apikey/apikey-list), create an account, and click **Create a new API key**.

View file

@ -50,7 +50,7 @@ const client = createOpencodeClient({
You can also programmatically start an opencode server:
````javascript
```javascript
import { createOpencodeServer } from "@opencode-ai/sdk"
const server = await createOpencodeServer({
@ -61,7 +61,7 @@ const server = await createOpencodeServer({
console.log(`Server running at ${server.url}`)
server.close()
}
```
#### Options
@ -307,8 +307,8 @@ await client.auth.set({
```javascript
// Listen to real-time events
const eventStream = await client.event.subscribe()
for await (const event of eventStream) {
const events = await client.event.subscribe()
for await (const event of events.stream) {
console.log("Event:", event.type, event.properties)
}
```

View file

@ -25,6 +25,12 @@ Once you're in the TUI, you can prompt it with a message.
Give me a quick summary of the codebase.
```
---
## File references
You can reference files in your messages using `@`. This does a fuzzy file search in the current working directory.
:::tip
You can also use `@` to reference files in your messages.
:::
@ -33,6 +39,20 @@ You can also use `@` to reference files in your messages.
How is auth handled in @packages/functions/src/api/index.ts?
```
The content of the file is added to the conversation automatically.
---
## Bash commands
Start a message with `!` to run a shell command.
```bash frame="none"
!ls -la
```
The output of the command is added to the conversation as a tool result.
---
## Commands
@ -235,18 +255,6 @@ Unshare current session. [Learn more](/docs/share#un-sharing).
---
## Bash commands
Start a message with `!` to run a shell command.
```bash frame="none"
!ls -la
```
The output of the command is added to the conversation as a tool result.
---
## Editor setup
Both the `/editor` and `/export` commands use the editor specified in your `EDITOR` environment variable.
@ -254,37 +262,54 @@ Both the `/editor` and `/export` commands use the editor specified in your `EDIT
<Tabs>
<TabItem label="Linux/macOS">
```bash
export EDITOR=nano # or vim, code, etc.
# Example for nano or vim
export EDITOR=nano
export EDITOR=vim
# For GUI editors (VS Code, Cursor, VSCodium, Windsurf, Zed, etc.) include --wait
export EDITOR="code --wait"
```
To make it permanent, add this to your shell profile;
`~/.bashrc`, `~/.zshrc`, etc.
</TabItem>
<TabItem label="Windows (CMD)">
```bash
set EDITOR=notepad # or code, vim, etc.
set EDITOR=notepad
# For GUI editors (VS Code, Cursor, VSCodium, Windsurf, Zed, etc.) include --wait
set EDITOR=code --wait
```
To make it permanent, use **System Properties** > **Environment
Variables**.
</TabItem>
<TabItem label="Windows (PowerShell)">
```bash
$env:EDITOR = "notepad" # or "code", "vim", etc.
```powershell
$env:EDITOR = "notepad"
# For GUI editors (VS Code, Cursor, VSCodium, Windsurf, Zed, etc.) include --wait
$env:EDITOR = "code --wait"
```
To make it permanent, add this to your PowerShell
profile.
To make it permanent, add this to your PowerShell profile.
</TabItem>
</Tabs>
Popular editor options include:
- `code` - Visual Studio Code
- `cursor` - Cursor
- `windsurf` - Windsurf
- `vim` - Vim editor
- `nano` - Nano editor
- `notepad` - Windows Notepad
- `subl` - Sublime Text
:::note
Some editors like VS Code need to be started with the `--wait` flag.
:::
Some editors need command-line arguments to run in blocking mode. The `--wait` flag makes the editor process block until closed.