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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/app/src/components/dialog-select-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>
Expand Down
103 changes: 8 additions & 95 deletions packages/opencode/src/bun/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun"
import { createRequire } from "module"
import { Lock } from "../util/lock"
import { copyPluginAssets } from "../util/asset-copy"

export namespace BunProc {
const log = Log.create({ service: "bun" })
Expand Down Expand Up @@ -66,19 +65,15 @@ export namespace BunProc {
using _ = await Lock.write("bun-install")

const mod = path.join(Global.Path.cache, "node_modules", pkg)
const bundledDir = path.join(Global.Path.cache, "bundled")
const bundledFile = path.join(bundledDir, `${pkg.replace(/\//g, "-")}.js`)
const pkgjson = Bun.file(path.join(Global.Path.cache, "package.json"))
const parsed = await pkgjson.json().catch(async () => {
const result = { dependencies: {}, bundled: {} }
const result = { dependencies: {} }
await Bun.write(pkgjson.name!, JSON.stringify(result, null, 2))
return result
})

// Check if already installed and bundled
const bundledExists = await Bun.file(bundledFile).exists()
if (parsed.dependencies[pkg] === version && bundledExists) {
return bundledFile
if (parsed.dependencies[pkg] === version) {
return mod
}

const proxied = !!(
Expand Down Expand Up @@ -129,97 +124,15 @@ export namespace BunProc {
resolvedVersion = installedPkg.version
}

const tslibPath = path.join(Global.Path.cache, "node_modules", "tslib", "package.json")
const tslibExists = await Bun.file(tslibPath).exists()
if (!tslibExists) {
const resolvedTslibVersion = "latest"
log.info("installing tslib dependency for runtime compatibility", {
pkg,
tslib: resolvedTslibVersion,
})
await BunProc.run([
"add",
"--force",
"--exact",
"--cwd",
Global.Path.cache,
`tslib@${resolvedTslibVersion}`,
], {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg: "tslib", version: resolvedTslibVersion },
{
cause: e,
},
)
})
}

// Bundle the plugin with all dependencies for compiled binary compatibility
// This creates a single file that doesn't require subpath export resolution
await Bun.file(bundledDir)
.exists()
.then(async (exists) => {
if (!exists) await Bun.$`mkdir -p ${bundledDir}`
})

// Find the entry point from package.json
const entryPoint = (installedPkg ?? {}).main || "index.js"
const entryPath = path.join(mod, entryPoint)
parsed.dependencies[pkg] = resolvedVersion
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))

log.info("bundling plugin for compiled binary compatibility", {
log.info("successfully installed plugin", {
pkg,
entryPath,
bundledFile,
path: mod,
})

try {
const result = await Bun.build({
entrypoints: [entryPath],
outdir: bundledDir,
naming: `${pkg.replace(/\//g, "-")}.js`,
target: "bun",
format: "esm",
// Bundle all dependencies to avoid subpath export resolution issues
packages: "bundle",
})

if (!result.success) {
log.error("failed to bundle plugin - falling back to unbundled module", {
pkg,
logs: result.logs,
unbundledPath: mod,
})
// Fall back to unbundled module
return mod
}

log.info("successfully bundled plugin", {
pkg,
bundledFile,
})

// Copy non-JS assets (HTML, CSS, etc.) that plugins may need at runtime
// Some bundled code uses __dirname + ".." to find assets, so copy to both
// the bundled dir and the parent cache dir for compatibility
await copyPluginAssets(mod, bundledDir)
await copyPluginAssets(mod, Global.Path.cache)
} catch (e) {
log.error("failed to bundle plugin - falling back to unbundled module", {
pkg,
error: (e as Error).message,
unbundledPath: mod,
})
// Fall back to unbundled module
return mod
}

parsed.dependencies[pkg] = resolvedVersion
if (!parsed.bundled) parsed.bundled = {}
parsed.bundled[pkg] = bundledFile
await Bun.write(pkgjson.name!, JSON.stringify(parsed, null, 2))
return bundledFile
return mod
}

}
2 changes: 0 additions & 2 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -341,8 +341,6 @@ export const AuthLoginCommand = cmd({
"Configure via opencode.json options (profile, region, endpoint) or\n" +
"AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID).",
)
prompts.outro("Done")
return
}

if (provider === "opencode") {
Expand Down
5 changes: 4 additions & 1 deletion packages/opencode/src/cli/cmd/tui/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ export const AttachCommand = cmd({
}),
handler: async (args) => {
if (args.dir) process.chdir(args.dir)
// Always pass client's cwd so attached sessions operate in the client's directory
// This ensures file autocomplete, theme discovery, and exports use the correct directory
const directory = process.cwd()
await tui({
url: args.url,
args: { sessionID: args.session },
directory: args.dir ? process.cwd() : undefined,
directory,
})
},
})
134 changes: 68 additions & 66 deletions packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,76 +33,82 @@ export function DialogModel(props: { providerID?: string }) {

const options = createMemo(() => {
const q = query()
const favorites = showExtra() ? local.model.favorite() : []
const needle = q.trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()

const recentList = showExtra()
const recentList = showSections
? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
: []

const favoriteOptions = favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
const favoriteOptions = showSections
? favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []

const recentOptions = recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
const recentOptions = showSections
? recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []

const providerOptions = pipe(
sync.data.provider,
Expand Down Expand Up @@ -145,6 +151,7 @@ export function DialogModel(props: { providerID?: string }) {
}
}),
filter((x) => {
if (!showSections) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
Expand Down Expand Up @@ -177,16 +184,11 @@ export function DialogModel(props: { providerID?: string }) {
)
: []

// Apply fuzzy filtering to each section separately, maintaining section order
if (q) {
const filteredFavorites = fuzzysort.go(q, favoriteOptions, { keys: ["title"] }).map((x) => x.obj)
const filteredRecents = fuzzysort
.go(q, recentOptions, { keys: ["title"] })
.map((x) => x.obj)
.slice(0, 5)
const filteredProviders = fuzzysort.go(q, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(q, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredFavorites, ...filteredRecents, ...filteredProviders, ...filteredPopular]
// Search shows a single merged list (favorites inline)
if (needle) {
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredProviders, ...filteredPopular]
}

return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/tui/component/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export const TIPS = [
"Press {highlight}Tab{/highlight} to cycle between Build (full access) and Plan (read-only) agents.",
"Use {highlight}/undo{/highlight} to revert the last message and any file changes made by OpenCode.",
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai.",
"Run {highlight}/share{/highlight} to create a public link to your conversation at shuv.ai.",
"Drag and drop images into the terminal to add them as context for your prompts.",
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard directly into the prompt.",
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor.",
Expand Down Expand Up @@ -61,7 +61,7 @@ export const TIPS = [
"Use {highlight}--format json{/highlight} for machine-readable output in scripts.",
"Run {highlight}opencode serve{/highlight} for headless API access to OpenCode.",
"Use {highlight}opencode run --attach{/highlight} to connect to a running server for faster runs.",
"Run {highlight}opencode upgrade{/highlight} to update to the latest version.",
"Run {highlight}shuvcode upgrade{/highlight} to update to the latest version.",
"Run {highlight}opencode auth list{/highlight} to see all configured providers.",
"Run {highlight}opencode agent create{/highlight} for guided agent creation.",
"Use {highlight}/opencode{/highlight} in GitHub issues/PRs to trigger AI actions.",
Expand Down Expand Up @@ -92,7 +92,7 @@ export const TIPS = [
"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.",
"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.",
"Run {highlight}docker run -it --rm ghcr.io/latitudes-dev/shuvcode{/highlight} for containerized use.",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models.",
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing.",
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs.",
Expand Down
Loading