Merge branch 'dev' into feat/canceled-prompts-in-history

This commit is contained in:
Ariane Emory
2026-02-04 19:33:59 -05:00
80 changed files with 2350 additions and 1130 deletions
+2 -8
View File
@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
export namespace BunProc {
const log = Log.create({ service: "bun" })
@@ -86,20 +87,13 @@ export namespace BunProc {
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
const proxied = !!(
process.env.HTTP_PROXY ||
process.env.HTTPS_PROXY ||
process.env.http_proxy ||
process.env.https_proxy
)
// Build command arguments
const args = [
"add",
"--force",
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied ? ["--no-cache"] : []),
...(proxied() ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
@@ -187,7 +187,6 @@ function App() {
const route = useRoute()
const dimensions = useTerminalDimensions()
const renderer = useRenderer()
Clipboard.setRenderer(renderer)
renderer.disableStdoutInterception()
const dialog = useDialog()
const local = useLocal()
@@ -1,12 +1,23 @@
import { $ } from "bun"
import type { CliRenderer } from "@opentui/core"
import { platform, release } from "os"
import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
const rendererRef = { current: undefined as CliRenderer | undefined }
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard {
export interface Content {
@@ -14,10 +25,6 @@ export namespace Clipboard {
mime: string
}
export function setRenderer(renderer: CliRenderer | undefined): void {
rendererRef.current = renderer
}
export async function read(): Promise<Content | undefined> {
const os = platform()
@@ -146,12 +153,7 @@ export namespace Clipboard {
})
export async function copy(text: string): Promise<void> {
const renderer = rendererRef.current
if (renderer) {
// Try OSC52 but don't early return - always fall back to native method
// OSC52 may report success but not actually work in all terminals
renderer.copyToClipboardOSC52(text)
}
writeOsc52(text)
await getCopyMethod()(text)
}
}
+21 -4
View File
@@ -29,6 +29,7 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
export namespace Config {
const log = Log.create({ service: "config" })
@@ -247,13 +248,29 @@ export namespace Config {
const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
cwd: dir,
}).catch(() => {})
await BunProc.run(
[
"add",
`@opencode-ai/plugin@${targetVersion}`,
"--exact",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{
cwd: dir,
},
).catch(() => {})
// Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() ? ["--no-cache"] : []),
],
{ cwd: dir },
).catch(() => {})
}
async function needsInstall(dir: string) {
+26 -7
View File
@@ -239,7 +239,9 @@ export namespace Provider {
options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) {
// Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
return sdk.languageModel(modelID)
}
@@ -455,6 +457,29 @@ export namespace Provider {
},
}
},
"cloudflare-workers-ai": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
if (!accountId) return { autoload: false }
const apiKey = await iife(async () => {
const envToken = Env.get("CLOUDFLARE_API_KEY")
if (envToken) return envToken
const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key
return undefined
})
return {
autoload: !!apiKey,
options: {
apiKey,
baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`,
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
},
}
},
"cloudflare-ai-gateway": async (input) => {
const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID")
const gateway = Env.get("CLOUDFLARE_GATEWAY_ID")
@@ -985,12 +1010,6 @@ export namespace Provider {
const fetchFn = customFetch ?? fetch
const opts = init ?? {}
// Merge configured headers into request headers
opts.headers = {
...(typeof opts.headers === "object" ? opts.headers : {}),
...options["headers"],
}
if (options["timeout"] !== undefined && options["timeout"] !== null) {
const signals: AbortSignal[] = []
if (opts.signal) signals.push(opts.signal)
@@ -630,6 +630,18 @@ export namespace ProviderTransform {
}
}
// Enable thinking by default for kimi-k2.5/k2p5 models using anthropic SDK
const modelId = input.model.api.id.toLowerCase()
if (
(input.model.api.npm === "@ai-sdk/anthropic" || input.model.api.npm === "@ai-sdk/google-vertex/anthropic") &&
(modelId.includes("k2p5") || modelId.includes("kimi-k2.5") || modelId.includes("kimi-k2p5"))
) {
result["thinking"] = {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(input.model.limit.output / 2 - 1)),
}
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
+5 -4
View File
@@ -17,13 +17,14 @@ const FILES = [
]
function globalFiles() {
const files = [path.join(Global.Path.config, "AGENTS.md")]
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
const files = []
if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
}
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files
}
+5 -3
View File
@@ -968,9 +968,11 @@ export namespace SessionPrompt {
// have to normalize, symbol search returns absolute paths
// Decode the pathname since URL constructor doesn't automatically decode it
const filepath = fileURLToPath(part.url)
const stat = await Bun.file(filepath).stat()
const stat = await Bun.file(filepath)
.stat()
.catch(() => undefined)
if (stat.isDirectory()) {
if (stat?.isDirectory()) {
part.mime = "application/x-directory"
}
@@ -989,7 +991,7 @@ export namespace SessionPrompt {
// workspace/symbol searches, so we'll try to find the
// symbol in the document to get the full range
if (start === end) {
const symbols = await LSP.documentSymbol(filePathURI)
const symbols = await LSP.documentSymbol(filePathURI).catch(() => [])
for (const symbol of symbols) {
let range: LSP.Range | undefined
if ("range" in symbol) {
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
},
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
try {
const headers: Record<string, string> = {
@@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
method: "POST",
headers,
body: JSON.stringify(codeRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]),
signal,
})
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
@@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
metadata: {},
}
} catch (error) {
clearTimeout(timeoutId)
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out")
+3 -5
View File
@@ -2,6 +2,7 @@ import z from "zod"
import { Tool } from "./tool"
import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -36,8 +37,7 @@ export const WebFetchTool = Tool.define("webfetch", {
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
// Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*"
@@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
}
const signal = AbortSignal.any([controller.signal, ctx.abort])
const headers = {
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
@@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", {
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`)
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod"
import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai",
@@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
},
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 25000)
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
try {
const headers: Record<string, string> = {
@@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
method: "POST",
headers,
body: JSON.stringify(searchRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]),
signal,
})
clearTimeout(timeoutId)
clearTimeout()
if (!response.ok) {
const errorText = await response.text()
@@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
metadata: {},
}
} catch (error) {
clearTimeout(timeoutId)
clearTimeout()
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out")
+35
View File
@@ -0,0 +1,35 @@
/**
* Creates an AbortController that automatically aborts after a timeout.
*
* Uses bind() instead of arrow functions to avoid capturing the surrounding
* scope in closures. Arrow functions like `() => controller.abort()` capture
* request bodies and other large objects, preventing GC for the timer lifetime.
*
* @param ms Timeout in milliseconds
* @returns Object with controller, signal, and clearTimeout function
*/
export function abortAfter(ms: number) {
const controller = new AbortController()
const id = setTimeout(controller.abort.bind(controller), ms)
return {
controller,
signal: controller.signal,
clearTimeout: () => globalThis.clearTimeout(id),
}
}
/**
* Combines multiple AbortSignals with a timeout.
*
* @param ms Timeout in milliseconds
* @param signals Additional signals to combine
* @returns Combined signal that aborts on timeout or when any input signal aborts
*/
export function abortAfterAny(ms: number, ...signals: AbortSignal[]) {
const timeout = abortAfter(ms)
const signal = AbortSignal.any([timeout.signal, ...signals])
return {
signal,
clearTimeout: timeout.clearTimeout,
}
}
+3
View File
@@ -0,0 +1,3 @@
export function proxied() {
return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy)
}